deepctl-core 0.2.19__tar.gz → 0.3.0__tar.gz

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.
Files changed (23) hide show
  1. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/PKG-INFO +1 -1
  2. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/pyproject.toml +1 -1
  3. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/auth.py +109 -217
  4. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/config.py +0 -3
  5. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core.egg-info/PKG-INFO +1 -1
  6. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/README.md +0 -0
  7. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/setup.cfg +0 -0
  8. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/__init__.py +0 -0
  9. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/base_command.py +0 -0
  10. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/base_group_command.py +0 -0
  11. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/client.py +0 -0
  12. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/models.py +0 -0
  13. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/output.py +0 -0
  14. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/plugin_env.py +0 -0
  15. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/plugin_manager.py +0 -0
  16. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/py.typed +0 -0
  17. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/skill_bundle.py +0 -0
  18. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/skill_generator.py +0 -0
  19. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core/timing.py +0 -0
  20. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core.egg-info/SOURCES.txt +0 -0
  21. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core.egg-info/dependency_links.txt +0 -0
  22. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core.egg-info/requires.txt +0 -0
  23. {deepctl_core-0.2.19 → deepctl_core-0.3.0}/src/deepctl_core.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-core
3
- Version: 0.2.19
3
+ Version: 0.3.0
4
4
  Summary: Core components for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepctl-core"
7
- version = "0.2.19" # x-release-please-version
7
+ version = "0.3.0" # x-release-please-version
8
8
  description = "Core components for deepctl"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,6 +8,7 @@ from urllib.parse import urlencode
8
8
 
9
9
  import httpx
10
10
  import keyring
11
+ from keyring.errors import PasswordDeleteError
11
12
  from pydantic import BaseModel
12
13
  from rich.progress import Progress, SpinnerColumn, TextColumn
13
14
 
@@ -25,8 +26,6 @@ console = stderr_console
25
26
  AUTH_BASE_URL = os.getenv("DEEPGRAM_CLI_BASE_URL", "https://id.dx.deepgram.com")
26
27
  DEVICE_CODE_URL = f"{AUTH_BASE_URL}/device/code"
27
28
  TOKEN_POLL_URL = f"{AUTH_BASE_URL}/device/token"
28
- TOKEN_REFRESH_URL = f"{AUTH_BASE_URL}/token"
29
- DG_CREDENTIALS_URL = f"{AUTH_BASE_URL}/credentials/token"
30
29
 
31
30
  # Registered OIDC client ID for the CLI
32
31
  CLIENT_ID = "deepgram-cli"
@@ -39,8 +38,9 @@ class DeviceCodeResponse(BaseModel):
39
38
  """Response from device code request."""
40
39
 
41
40
  device_code: str
42
- user_code: str | None = None # Not used in current implementation
41
+ user_code: str | None = None
43
42
  verification_uri: str
43
+ verification_uri_complete: str | None = None
44
44
  expires_in: int
45
45
  interval: int
46
46
 
@@ -50,15 +50,8 @@ class TokenResponse(BaseModel):
50
50
 
51
51
  access_token: str
52
52
  project_id: str
53
- refresh_token: str | None = None # Present in new JWT-based device flow
54
- token_type: str | None = None
55
- expires_in: int | None = None
56
- scope: str | None = None
57
-
58
- @property
59
- def api_key(self) -> str:
60
- """Get the API key from the response."""
61
- return self.access_token
53
+ dg_token: str | None = None
54
+ dg_expires_in: int | None = None
62
55
 
63
56
 
64
57
  class AuthenticationError(Exception):
@@ -113,19 +106,21 @@ class AuthManager:
113
106
  """
114
107
  profile_name = profile_name or self.config.profile or "default"
115
108
 
116
- # Check keyring for direct API key or JWT (both indicate logged-in state)
109
+ # Check keyring for a direct API key.
117
110
  has_api_key = False
118
- try:
119
- api_key = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
120
- jwt = keyring.get_password(KEYRING_SERVICE, f"jwt.{profile_name}")
121
- has_api_key = bool(api_key or jwt)
122
- except Exception:
123
- pass
111
+ expired, _ = self._credential_expiry(profile_name)
112
+ if not expired:
113
+ try:
114
+ has_api_key = bool(
115
+ keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
116
+ )
117
+ except Exception:
118
+ pass
124
119
 
125
- # If not in keyring, check config
126
- if not has_api_key:
127
- profile = self.config.get_profile(profile_name)
128
- has_api_key = bool(profile.api_key)
120
+ # If not in keyring, check config.
121
+ if not has_api_key:
122
+ profile = self.config.get_profile(profile_name)
123
+ has_api_key = bool(profile.api_key)
129
124
 
130
125
  # Check for project ID in profile
131
126
  profile = self.config.get_profile(profile_name)
@@ -159,15 +154,11 @@ class AuthManager:
159
154
  return bool(os.getenv("DEEPGRAM_API_KEY") and os.getenv("DEEPGRAM_PROJECT_ID"))
160
155
 
161
156
  def get_api_key(self, ignore_env: bool = False) -> str | None:
162
- """Get API key following precedence: explicit > JWT flow > legacy keyring > env.
163
-
164
- When the user logged in via device flow with the new JWT-based server,
165
- this method transparently refreshes the JWT if expired and exchanges it
166
- for a Deepgram API token (dg_token). All callers receive a usable
167
- Deepgram token regardless of which auth path was used.
157
+ """Get API key following precedence: explicit > profile > env.
168
158
 
169
- Passing --api-key (or DEEPGRAM_API_KEY) bypasses the JWT flow entirely,
170
- which is the correct behaviour for CI/CD and direct key usage.
159
+ Expired finite profile credentials are treated as absent, allowing an
160
+ explicit key or ``DEEPGRAM_API_KEY`` to continue to work. If no other
161
+ credential is available, raise an actionable login error.
171
162
 
172
163
  Args:
173
164
  ignore_env: If True, don't check environment variables
@@ -177,27 +168,24 @@ class AuthManager:
177
168
  return self.explicit_api_key
178
169
 
179
170
  profile_name = self.config.profile or "default"
171
+ current_profile = self.config.get_profile(profile_name)
180
172
 
181
- # JWT-based flow: present when logged in via device flow with new server.
182
- try:
183
- jwt = keyring.get_password(KEYRING_SERVICE, f"jwt.{profile_name}")
184
- if jwt:
185
- return self._get_dg_token_via_jwt(jwt, profile_name)
186
- except Exception:
187
- pass
188
-
189
- # Legacy path: direct API key in keyring (--api-key login, old device flow).
190
- try:
191
- api_key = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
192
- if api_key:
193
- return api_key
194
- except Exception:
195
- pass
173
+ expired, expiry_error = self._credential_expiry(profile_name)
174
+ if not expired:
175
+ # Profile keyring entry, followed by its config-file fallback.
176
+ try:
177
+ api_key = keyring.get_password(
178
+ KEYRING_SERVICE, f"api-key.{profile_name}"
179
+ )
180
+ if api_key:
181
+ return api_key
182
+ except Exception:
183
+ pass
196
184
 
197
- # Config file fallback (keyring unavailable on this machine).
198
- current_profile = self.config.get_profile(profile_name)
199
- if current_profile.api_key:
200
- return current_profile.api_key
185
+ if current_profile.api_key:
186
+ return current_profile.api_key
187
+ else:
188
+ self._delete_keyring_credential(f"api-key.{profile_name}")
201
189
 
202
190
  # Environment variable — lowest priority.
203
191
  if not ignore_env:
@@ -205,8 +193,43 @@ class AuthManager:
205
193
  if api_key:
206
194
  return api_key
207
195
 
196
+ if expiry_error:
197
+ raise AuthenticationError(expiry_error)
198
+
208
199
  return None
209
200
 
201
+ def _credential_expiry(self, profile_name: str) -> tuple[bool, str | None]:
202
+ """Return whether a finite profile credential is unusable and why."""
203
+ expires_at = self.config.get_profile(profile_name).dg_token_expires_at
204
+ if not isinstance(expires_at, str):
205
+ return False, None
206
+
207
+ try:
208
+ parsed_expiry = datetime.fromisoformat(expires_at)
209
+ if parsed_expiry.tzinfo is None:
210
+ raise ValueError
211
+ except (TypeError, ValueError):
212
+ return (
213
+ True,
214
+ "Your Deepgram credential expiry is invalid. Run `dg login` again.",
215
+ )
216
+
217
+ if parsed_expiry <= datetime.now(timezone.utc):
218
+ return True, "Your Deepgram credential has expired. Run `dg login` again."
219
+
220
+ return False, None
221
+
222
+ def _delete_keyring_credential(self, keyring_key: str) -> None:
223
+ """Remove a credential when present without masking keyring failures."""
224
+ try:
225
+ keyring.delete_password(KEYRING_SERVICE, keyring_key)
226
+ except PasswordDeleteError:
227
+ pass
228
+ except Exception as exc:
229
+ console.print(
230
+ f"[yellow]Warning:[/yellow] Could not remove legacy credential: {exc}"
231
+ )
232
+
210
233
  def get_project_id(self, ignore_env: bool = False) -> str | None:
211
234
  """Get project ID following precedence: explicit > profile > env.
212
235
 
@@ -501,14 +524,9 @@ class AuthManager:
501
524
  # Request device code from dx-id auth provider
502
525
  device_response = self._request_device_code()
503
526
 
504
- # Build verification URI with query parameters
505
- query_params = {
506
- "device_code": device_response.device_code,
507
- "client_id": CLIENT_ID,
508
- "hostname": hostname,
509
- }
510
527
  verification_uri = (
511
- f"{device_response.verification_uri}?{urlencode(query_params)}"
528
+ device_response.verification_uri_complete
529
+ or device_response.verification_uri
512
530
  )
513
531
 
514
532
  # Open browser immediately and start polling
@@ -516,6 +534,8 @@ class AuthManager:
516
534
  "\n[bold]Hello from Deepgram![/bold] Opening browser to "
517
535
  "complete login..."
518
536
  )
537
+ if device_response.user_code:
538
+ console.print(f"[bold]Enter code:[/bold] {device_response.user_code}")
519
539
 
520
540
  try:
521
541
  webbrowser.open(verification_uri)
@@ -552,7 +572,7 @@ class AuthManager:
552
572
  payload = {
553
573
  "client_id": CLIENT_ID,
554
574
  "hostname": hostname,
555
- "scopes": "admin",
575
+ "scopes": "openid profile email",
556
576
  }
557
577
 
558
578
  try:
@@ -628,62 +648,28 @@ class AuthManager:
628
648
  def _store_token(self, token_response: TokenResponse) -> None:
629
649
  """Store authentication token.
630
650
 
631
- Handles two server response shapes:
632
- - New JWT flow: access_token is a short-lived JWT (expires_in=900),
633
- refresh_token present. Stores JWT + refresh_token in keyring.
634
- - Legacy flow: access_token is a Deepgram API key directly.
635
- Stored under api-key.{profile} as before.
651
+ Stores a direct finite Deepgram credential from dx-id. The credential
652
+ expires independently of the OIDC access token and requires a new
653
+ device authorization when it expires.
636
654
  """
637
655
  profile_name = self.config.profile or "default"
638
656
 
639
- if token_response.refresh_token:
640
- # New JWT-based device flow.
641
- jwt = token_response.access_token
642
- refresh_token = token_response.refresh_token
643
- expires_in = token_response.expires_in or 900
657
+ if token_response.dg_token:
658
+ api_key = token_response.dg_token
659
+ expires_in = token_response.dg_expires_in or 0
660
+ if expires_in <= 0:
661
+ raise AuthenticationError(
662
+ "Deepgram sign-in did not provide a finite credential expiry"
663
+ )
644
664
  expires_at = (
645
665
  datetime.now(timezone.utc) + timedelta(seconds=expires_in)
646
666
  ).isoformat()
647
667
 
648
- try:
649
- # Clear any stale direct API key so get_api_key() doesn't
650
- # return a cached dg_token from a previous session.
651
- keyring.delete_password(KEYRING_SERVICE, f"api-key.{profile_name}")
652
- except Exception:
653
- pass
654
-
655
- try:
656
- keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", jwt)
657
- keyring.set_password(
658
- KEYRING_SERVICE, f"refresh-token.{profile_name}", refresh_token
659
- )
660
- console.print(
661
- "[green]✓[/green] Session stored securely in system keyring"
662
- )
663
- except Exception as e:
664
- console.print(
665
- f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
666
- )
667
-
668
- # Store expiry timestamps and project ID in config (non-sensitive).
669
- profile = self.config.get_profile(profile_name)
670
- profile.jwt_expires_at = expires_at
671
- profile.project_id = token_response.project_id
672
- self.config.save()
673
-
674
- else:
675
- # Legacy flow: access_token is the Deepgram API key directly.
676
- api_key = token_response.access_token
677
- project_id = token_response.project_id
678
668
  keyring_available = False
679
-
680
669
  try:
681
670
  keyring.set_password(
682
671
  KEYRING_SERVICE, f"api-key.{profile_name}", api_key
683
672
  )
684
- console.print(
685
- "[green]✓[/green] API key stored securely in system keyring"
686
- )
687
673
  keyring_available = True
688
674
  except Exception as e:
689
675
  console.print(
@@ -691,11 +677,31 @@ class AuthManager:
691
677
  )
692
678
  console.print("API key will be stored in config file instead")
693
679
 
680
+ for keyring_key in (f"jwt.{profile_name}", f"refresh-token.{profile_name}"):
681
+ self._delete_keyring_credential(keyring_key)
682
+
694
683
  self.config.create_profile(
695
684
  profile_name,
696
685
  api_key=api_key if not keyring_available else None,
697
- project_id=project_id,
686
+ project_id=token_response.project_id,
687
+ base_url=self.config.get_profile(profile_name).base_url,
698
688
  )
689
+ profile = self.config.get_profile(profile_name)
690
+ profile.dg_token_expires_at = expires_at
691
+ self.config.save()
692
+ if keyring_available:
693
+ console.print(
694
+ "[green]✓[/green] API key stored securely in system keyring"
695
+ )
696
+ else:
697
+ console.print(
698
+ "[yellow]Warning:[/yellow] API key stored in the config file fallback"
699
+ )
700
+ return
701
+
702
+ raise AuthenticationError(
703
+ "Deepgram sign-in did not return a finite credential. Run `dg update` and `dg login` again."
704
+ )
699
705
 
700
706
  def logout(self, keep_config: bool = False) -> None:
701
707
  """Logout user and clear credentials.
@@ -769,120 +775,6 @@ class AuthManager:
769
775
  current_profile=self.config.profile or self.config._config.default_profile,
770
776
  )
771
777
 
772
- def _get_dg_token_via_jwt(self, jwt: str, profile_name: str) -> str:
773
- """Return a valid Deepgram API token, refreshing as needed.
774
-
775
- Checks the cached dg_token first; refreshes the JWT if expired; then
776
- exchanges the JWT for a fresh dg_token when the cached one is stale.
777
- """
778
- profile = self.config.get_profile(profile_name)
779
- now = datetime.now(timezone.utc)
780
-
781
- # Return cached dg_token if it is still valid.
782
- try:
783
- dg_token = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
784
- if dg_token and profile.dg_token_expires_at:
785
- expires = datetime.fromisoformat(profile.dg_token_expires_at)
786
- if now < expires:
787
- return dg_token
788
- except Exception:
789
- pass
790
-
791
- # Refresh the JWT if it has expired.
792
- current_jwt = jwt
793
- if profile.jwt_expires_at:
794
- jwt_expires = datetime.fromisoformat(profile.jwt_expires_at)
795
- if now >= jwt_expires:
796
- current_jwt = self._refresh_jwt(profile_name)
797
-
798
- return self._exchange_for_dg_token(current_jwt, profile_name)
799
-
800
- def _refresh_jwt(self, profile_name: str) -> str:
801
- """Exchange a refresh token for a new JWT."""
802
- try:
803
- refresh_token = keyring.get_password(
804
- KEYRING_SERVICE, f"refresh-token.{profile_name}"
805
- )
806
- except Exception:
807
- refresh_token = None
808
-
809
- if not refresh_token:
810
- raise AuthenticationError(
811
- "Session expired and no refresh token is available. "
812
- "Please run 'dg login' again."
813
- )
814
-
815
- try:
816
- response = self.client.post(
817
- TOKEN_REFRESH_URL,
818
- json={"grant_type": "refresh_token", "refresh_token": refresh_token},
819
- )
820
- except httpx.RequestError as e:
821
- raise AuthenticationError(f"Network error during token refresh: {e}")
822
-
823
- if response.status_code not in (200, 201):
824
- raise AuthenticationError(
825
- f"Token refresh failed (HTTP {response.status_code}). "
826
- "Please run 'dg login' again."
827
- )
828
-
829
- data = response.json()
830
- new_jwt: str = data["access_token"]
831
- new_refresh: str = data.get("refresh_token", refresh_token)
832
- expires_in: int = data.get("expires_in", 900)
833
- expires_at = (
834
- datetime.now(timezone.utc) + timedelta(seconds=expires_in)
835
- ).isoformat()
836
-
837
- try:
838
- keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", new_jwt)
839
- if new_refresh != refresh_token:
840
- keyring.set_password(
841
- KEYRING_SERVICE, f"refresh-token.{profile_name}", new_refresh
842
- )
843
- except Exception:
844
- pass
845
-
846
- profile = self.config.get_profile(profile_name)
847
- profile.jwt_expires_at = expires_at
848
- self.config.save()
849
-
850
- return new_jwt
851
-
852
- def _exchange_for_dg_token(self, jwt: str, profile_name: str) -> str:
853
- """Exchange a JWT for a Deepgram API token (dg_token)."""
854
- try:
855
- response = self.client.post(
856
- DG_CREDENTIALS_URL,
857
- headers={"Authorization": f"Bearer {jwt}"},
858
- )
859
- except httpx.RequestError as e:
860
- raise AuthenticationError(f"Network error during credentials exchange: {e}")
861
-
862
- if response.status_code not in (200, 201):
863
- raise AuthenticationError(
864
- f"Credentials exchange failed (HTTP {response.status_code}). "
865
- "Please run 'dg login' again."
866
- )
867
-
868
- data = response.json()
869
- dg_token: str = data["dg_token"]
870
- expires_in: int = data.get("expires_in", 86400)
871
- expires_at = (
872
- datetime.now(timezone.utc) + timedelta(seconds=expires_in)
873
- ).isoformat()
874
-
875
- try:
876
- keyring.set_password(KEYRING_SERVICE, f"api-key.{profile_name}", dg_token)
877
- except Exception:
878
- pass # Non-fatal: will re-exchange next time
879
-
880
- profile = self.config.get_profile(profile_name)
881
- profile.dg_token_expires_at = expires_at
882
- self.config.save()
883
-
884
- return dg_token
885
-
886
778
  def __del__(self) -> None:
887
779
  """Cleanup HTTP client."""
888
780
  if hasattr(self, "client"):
@@ -15,9 +15,6 @@ class ProfileConfig(BaseModel):
15
15
  api_key: str | None = None
16
16
  project_id: str | None = None
17
17
  base_url: str = "https://api.deepgram.com"
18
- # JWT-based auth (device flow with new server): expiry timestamps stored
19
- # here (non-sensitive); the tokens themselves live in the keyring.
20
- jwt_expires_at: str | None = None
21
18
  dg_token_expires_at: str | None = None
22
19
 
23
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-core
3
- Version: 0.2.19
3
+ Version: 0.3.0
4
4
  Summary: Core components for deepctl
5
5
  Author-email: Deepgram <devrel@deepgram.com>
6
6
  Maintainer-email: Deepgram <devrel@deepgram.com>
File without changes
File without changes