deepctl-core 0.2.7__tar.gz → 0.2.9__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.
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/PKG-INFO +1 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/pyproject.toml +1 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/auth.py +220 -34
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/config.py +4 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/plugin_env.py +39 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/plugin_manager.py +39 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core.egg-info/PKG-INFO +1 -1
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/README.md +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/setup.cfg +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/__init__.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/base_command.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/base_group_command.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/client.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/models.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/output.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/py.typed +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/skill_generator.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core/timing.py +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core.egg-info/SOURCES.txt +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core.egg-info/dependency_links.txt +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core.egg-info/requires.txt +0 -0
- {deepctl_core-0.2.7 → deepctl_core-0.2.9}/src/deepctl_core.egg-info/top_level.txt +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import time
|
|
5
5
|
import webbrowser
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
6
7
|
from urllib.parse import urlencode
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
@@ -20,6 +21,8 @@ console = Console()
|
|
|
20
21
|
AUTH_BASE_URL = os.getenv("DEEPGRAM_CLI_BASE_URL", "https://id.dx.deepgram.com")
|
|
21
22
|
DEVICE_CODE_URL = f"{AUTH_BASE_URL}/device/code"
|
|
22
23
|
TOKEN_POLL_URL = f"{AUTH_BASE_URL}/device/token"
|
|
24
|
+
TOKEN_REFRESH_URL = f"{AUTH_BASE_URL}/token"
|
|
25
|
+
DG_CREDENTIALS_URL = f"{AUTH_BASE_URL}/credentials/token"
|
|
23
26
|
|
|
24
27
|
# Registered OIDC client ID for the CLI
|
|
25
28
|
CLIENT_ID = "deepgram-cli"
|
|
@@ -43,6 +46,7 @@ class TokenResponse(BaseModel):
|
|
|
43
46
|
|
|
44
47
|
access_token: str
|
|
45
48
|
project_id: str
|
|
49
|
+
refresh_token: str | None = None # Present in new JWT-based device flow
|
|
46
50
|
token_type: str | None = None
|
|
47
51
|
expires_in: int | None = None
|
|
48
52
|
scope: str | None = None
|
|
@@ -105,11 +109,12 @@ class AuthManager:
|
|
|
105
109
|
"""
|
|
106
110
|
profile_name = profile_name or self.config.profile or "default"
|
|
107
111
|
|
|
108
|
-
# Check keyring for API key
|
|
112
|
+
# Check keyring for direct API key or JWT (both indicate logged-in state)
|
|
109
113
|
has_api_key = False
|
|
110
114
|
try:
|
|
111
115
|
api_key = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
112
|
-
|
|
116
|
+
jwt = keyring.get_password(KEYRING_SERVICE, f"jwt.{profile_name}")
|
|
117
|
+
has_api_key = bool(api_key or jwt)
|
|
113
118
|
except Exception:
|
|
114
119
|
pass
|
|
115
120
|
|
|
@@ -150,32 +155,47 @@ class AuthManager:
|
|
|
150
155
|
return bool(os.getenv("DEEPGRAM_API_KEY") and os.getenv("DEEPGRAM_PROJECT_ID"))
|
|
151
156
|
|
|
152
157
|
def get_api_key(self, ignore_env: bool = False) -> str | None:
|
|
153
|
-
"""Get API key following precedence: explicit >
|
|
158
|
+
"""Get API key following precedence: explicit > JWT flow > legacy keyring > env.
|
|
159
|
+
|
|
160
|
+
When the user logged in via device flow with the new JWT-based server,
|
|
161
|
+
this method transparently refreshes the JWT if expired and exchanges it
|
|
162
|
+
for a Deepgram API token (dg_token). All callers receive a usable
|
|
163
|
+
Deepgram token regardless of which auth path was used.
|
|
164
|
+
|
|
165
|
+
Passing --api-key (or DEEPGRAM_API_KEY) bypasses the JWT flow entirely,
|
|
166
|
+
which is the correct behaviour for CI/CD and direct key usage.
|
|
154
167
|
|
|
155
168
|
Args:
|
|
156
169
|
ignore_env: If True, don't check environment variables
|
|
157
170
|
"""
|
|
158
|
-
# Explicit credentials
|
|
171
|
+
# Explicit credentials (--api-key flag, CI/CD) bypass the JWT flow.
|
|
159
172
|
if self.explicit_api_key:
|
|
160
173
|
return self.explicit_api_key
|
|
161
174
|
|
|
162
|
-
# Check profile credentials next
|
|
163
175
|
profile_name = self.config.profile or "default"
|
|
164
176
|
|
|
165
|
-
#
|
|
177
|
+
# JWT-based flow: present when logged in via device flow with new server.
|
|
178
|
+
try:
|
|
179
|
+
jwt = keyring.get_password(KEYRING_SERVICE, f"jwt.{profile_name}")
|
|
180
|
+
if jwt:
|
|
181
|
+
return self._get_dg_token_via_jwt(jwt, profile_name)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
# Legacy path: direct API key in keyring (--api-key login, old device flow).
|
|
166
186
|
try:
|
|
167
187
|
api_key = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
168
188
|
if api_key:
|
|
169
189
|
return api_key
|
|
170
190
|
except Exception:
|
|
171
|
-
pass
|
|
191
|
+
pass
|
|
172
192
|
|
|
173
|
-
#
|
|
193
|
+
# Config file fallback (keyring unavailable on this machine).
|
|
174
194
|
current_profile = self.config.get_profile(profile_name)
|
|
175
195
|
if current_profile.api_key:
|
|
176
196
|
return current_profile.api_key
|
|
177
197
|
|
|
178
|
-
# Environment variable
|
|
198
|
+
# Environment variable — lowest priority.
|
|
179
199
|
if not ignore_env:
|
|
180
200
|
api_key = os.getenv("DEEPGRAM_API_KEY")
|
|
181
201
|
if api_key:
|
|
@@ -596,29 +616,76 @@ class AuthManager:
|
|
|
596
616
|
raise AuthenticationError("Authentication timed out")
|
|
597
617
|
|
|
598
618
|
def _store_token(self, token_response: TokenResponse) -> None:
|
|
599
|
-
"""Store authentication token.
|
|
600
|
-
# The access_token from dx-id is the Deepgram API key
|
|
601
|
-
api_key = token_response.access_token
|
|
602
|
-
project_id = token_response.project_id
|
|
619
|
+
"""Store authentication token.
|
|
603
620
|
|
|
621
|
+
Handles two server response shapes:
|
|
622
|
+
- New JWT flow: access_token is a short-lived JWT (expires_in=900),
|
|
623
|
+
refresh_token present. Stores JWT + refresh_token in keyring.
|
|
624
|
+
- Legacy flow: access_token is a Deepgram API key directly.
|
|
625
|
+
Stored under api-key.{profile} as before.
|
|
626
|
+
"""
|
|
604
627
|
profile_name = self.config.profile or "default"
|
|
605
|
-
keyring_available = False
|
|
606
628
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
629
|
+
if token_response.refresh_token:
|
|
630
|
+
# New JWT-based device flow.
|
|
631
|
+
jwt = token_response.access_token
|
|
632
|
+
refresh_token = token_response.refresh_token
|
|
633
|
+
expires_in = token_response.expires_in or 900
|
|
634
|
+
expires_at = (
|
|
635
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
636
|
+
).isoformat()
|
|
615
637
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
638
|
+
try:
|
|
639
|
+
# Clear any stale direct API key so get_api_key() doesn't
|
|
640
|
+
# return a cached dg_token from a previous session.
|
|
641
|
+
keyring.delete_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", jwt)
|
|
647
|
+
keyring.set_password(
|
|
648
|
+
KEYRING_SERVICE, f"refresh-token.{profile_name}", refresh_token
|
|
649
|
+
)
|
|
650
|
+
console.print(
|
|
651
|
+
"[green]✓[/green] Session stored securely in system keyring"
|
|
652
|
+
)
|
|
653
|
+
except Exception as e:
|
|
654
|
+
console.print(
|
|
655
|
+
f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# Store expiry timestamps and project ID in config (non-sensitive).
|
|
659
|
+
profile = self.config.get_profile(profile_name)
|
|
660
|
+
profile.jwt_expires_at = expires_at
|
|
661
|
+
profile.project_id = token_response.project_id
|
|
662
|
+
self.config.save()
|
|
663
|
+
|
|
664
|
+
else:
|
|
665
|
+
# Legacy flow: access_token is the Deepgram API key directly.
|
|
666
|
+
api_key = token_response.access_token
|
|
667
|
+
project_id = token_response.project_id
|
|
668
|
+
keyring_available = False
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
keyring.set_password(
|
|
672
|
+
KEYRING_SERVICE, f"api-key.{profile_name}", api_key
|
|
673
|
+
)
|
|
674
|
+
console.print(
|
|
675
|
+
"[green]✓[/green] API key stored securely in system keyring"
|
|
676
|
+
)
|
|
677
|
+
keyring_available = True
|
|
678
|
+
except Exception as e:
|
|
679
|
+
console.print(
|
|
680
|
+
f"[yellow]Warning:[/yellow] Could not store in keyring: {e}"
|
|
681
|
+
)
|
|
682
|
+
console.print("API key will be stored in config file instead")
|
|
683
|
+
|
|
684
|
+
self.config.create_profile(
|
|
685
|
+
profile_name,
|
|
686
|
+
api_key=api_key if not keyring_available else None,
|
|
687
|
+
project_id=project_id,
|
|
688
|
+
)
|
|
622
689
|
|
|
623
690
|
def logout(self, keep_config: bool = False) -> None:
|
|
624
691
|
"""Logout user and clear credentials.
|
|
@@ -629,12 +696,17 @@ class AuthManager:
|
|
|
629
696
|
"""
|
|
630
697
|
profile_name = self.config.profile or "default"
|
|
631
698
|
|
|
632
|
-
# Clear
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
699
|
+
# Clear all credential types from keyring.
|
|
700
|
+
for keyring_key in (
|
|
701
|
+
f"api-key.{profile_name}",
|
|
702
|
+
f"jwt.{profile_name}",
|
|
703
|
+
f"refresh-token.{profile_name}",
|
|
704
|
+
):
|
|
705
|
+
try:
|
|
706
|
+
keyring.delete_password(KEYRING_SERVICE, keyring_key)
|
|
707
|
+
except Exception:
|
|
708
|
+
pass
|
|
709
|
+
console.print("[dim]Cleared credentials from system keyring[/dim]")
|
|
638
710
|
|
|
639
711
|
if keep_config:
|
|
640
712
|
# Clear sensitive data from config but keep profile
|
|
@@ -687,6 +759,120 @@ class AuthManager:
|
|
|
687
759
|
current_profile=self.config.profile or self.config._config.default_profile,
|
|
688
760
|
)
|
|
689
761
|
|
|
762
|
+
def _get_dg_token_via_jwt(self, jwt: str, profile_name: str) -> str:
|
|
763
|
+
"""Return a valid Deepgram API token, refreshing as needed.
|
|
764
|
+
|
|
765
|
+
Checks the cached dg_token first; refreshes the JWT if expired; then
|
|
766
|
+
exchanges the JWT for a fresh dg_token when the cached one is stale.
|
|
767
|
+
"""
|
|
768
|
+
profile = self.config.get_profile(profile_name)
|
|
769
|
+
now = datetime.now(timezone.utc)
|
|
770
|
+
|
|
771
|
+
# Return cached dg_token if it is still valid.
|
|
772
|
+
try:
|
|
773
|
+
dg_token = keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
|
|
774
|
+
if dg_token and profile.dg_token_expires_at:
|
|
775
|
+
expires = datetime.fromisoformat(profile.dg_token_expires_at)
|
|
776
|
+
if now < expires:
|
|
777
|
+
return dg_token
|
|
778
|
+
except Exception:
|
|
779
|
+
pass
|
|
780
|
+
|
|
781
|
+
# Refresh the JWT if it has expired.
|
|
782
|
+
current_jwt = jwt
|
|
783
|
+
if profile.jwt_expires_at:
|
|
784
|
+
jwt_expires = datetime.fromisoformat(profile.jwt_expires_at)
|
|
785
|
+
if now >= jwt_expires:
|
|
786
|
+
current_jwt = self._refresh_jwt(profile_name)
|
|
787
|
+
|
|
788
|
+
return self._exchange_for_dg_token(current_jwt, profile_name)
|
|
789
|
+
|
|
790
|
+
def _refresh_jwt(self, profile_name: str) -> str:
|
|
791
|
+
"""Exchange a refresh token for a new JWT."""
|
|
792
|
+
try:
|
|
793
|
+
refresh_token = keyring.get_password(
|
|
794
|
+
KEYRING_SERVICE, f"refresh-token.{profile_name}"
|
|
795
|
+
)
|
|
796
|
+
except Exception:
|
|
797
|
+
refresh_token = None
|
|
798
|
+
|
|
799
|
+
if not refresh_token:
|
|
800
|
+
raise AuthenticationError(
|
|
801
|
+
"Session expired and no refresh token is available. "
|
|
802
|
+
"Please run 'dg login' again."
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
response = self.client.post(
|
|
807
|
+
TOKEN_REFRESH_URL,
|
|
808
|
+
json={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
809
|
+
)
|
|
810
|
+
except httpx.RequestError as e:
|
|
811
|
+
raise AuthenticationError(f"Network error during token refresh: {e}")
|
|
812
|
+
|
|
813
|
+
if response.status_code not in (200, 201):
|
|
814
|
+
raise AuthenticationError(
|
|
815
|
+
f"Token refresh failed (HTTP {response.status_code}). "
|
|
816
|
+
"Please run 'dg login' again."
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
data = response.json()
|
|
820
|
+
new_jwt: str = data["access_token"]
|
|
821
|
+
new_refresh: str = data.get("refresh_token", refresh_token)
|
|
822
|
+
expires_in: int = data.get("expires_in", 900)
|
|
823
|
+
expires_at = (
|
|
824
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
825
|
+
).isoformat()
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", new_jwt)
|
|
829
|
+
if new_refresh != refresh_token:
|
|
830
|
+
keyring.set_password(
|
|
831
|
+
KEYRING_SERVICE, f"refresh-token.{profile_name}", new_refresh
|
|
832
|
+
)
|
|
833
|
+
except Exception:
|
|
834
|
+
pass
|
|
835
|
+
|
|
836
|
+
profile = self.config.get_profile(profile_name)
|
|
837
|
+
profile.jwt_expires_at = expires_at
|
|
838
|
+
self.config.save()
|
|
839
|
+
|
|
840
|
+
return new_jwt
|
|
841
|
+
|
|
842
|
+
def _exchange_for_dg_token(self, jwt: str, profile_name: str) -> str:
|
|
843
|
+
"""Exchange a JWT for a Deepgram API token (dg_token)."""
|
|
844
|
+
try:
|
|
845
|
+
response = self.client.post(
|
|
846
|
+
DG_CREDENTIALS_URL,
|
|
847
|
+
headers={"Authorization": f"Bearer {jwt}"},
|
|
848
|
+
)
|
|
849
|
+
except httpx.RequestError as e:
|
|
850
|
+
raise AuthenticationError(f"Network error during credentials exchange: {e}")
|
|
851
|
+
|
|
852
|
+
if response.status_code not in (200, 201):
|
|
853
|
+
raise AuthenticationError(
|
|
854
|
+
f"Credentials exchange failed (HTTP {response.status_code}). "
|
|
855
|
+
"Please run 'dg login' again."
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
data = response.json()
|
|
859
|
+
dg_token: str = data["dg_token"]
|
|
860
|
+
expires_in: int = data.get("expires_in", 86400)
|
|
861
|
+
expires_at = (
|
|
862
|
+
datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
863
|
+
).isoformat()
|
|
864
|
+
|
|
865
|
+
try:
|
|
866
|
+
keyring.set_password(KEYRING_SERVICE, f"api-key.{profile_name}", dg_token)
|
|
867
|
+
except Exception:
|
|
868
|
+
pass # Non-fatal: will re-exchange next time
|
|
869
|
+
|
|
870
|
+
profile = self.config.get_profile(profile_name)
|
|
871
|
+
profile.dg_token_expires_at = expires_at
|
|
872
|
+
self.config.save()
|
|
873
|
+
|
|
874
|
+
return dg_token
|
|
875
|
+
|
|
690
876
|
def __del__(self) -> None:
|
|
691
877
|
"""Cleanup HTTP client."""
|
|
692
878
|
if hasattr(self, "client"):
|
|
@@ -15,6 +15,10 @@ 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
|
+
dg_token_expires_at: str | None = None
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
class OutputConfig(BaseModel):
|
|
@@ -96,6 +96,45 @@ def get_venv_python() -> str | None:
|
|
|
96
96
|
return str(python) if python.exists() else None
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def get_venv_python_version() -> tuple[int, int] | None:
|
|
100
|
+
"""Read the (major, minor) Python version that built the plugin venv.
|
|
101
|
+
|
|
102
|
+
Reads ``pyvenv.cfg`` from :data:`PLUGIN_VENV`. Both ``version`` (stdlib
|
|
103
|
+
``python -m venv``) and ``version_info`` (uv-created venvs) are accepted.
|
|
104
|
+
|
|
105
|
+
Returns ``None`` when the venv doesn't exist, the cfg file is missing,
|
|
106
|
+
or the version line can't be parsed — callers should treat ``None`` as
|
|
107
|
+
"unknown" and skip ABI-mismatch checks rather than failing loudly.
|
|
108
|
+
"""
|
|
109
|
+
if not PLUGIN_VENV.exists():
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
cfg = PLUGIN_VENV / "pyvenv.cfg"
|
|
113
|
+
if not cfg.exists():
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
text = cfg.read_text()
|
|
118
|
+
except OSError:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
for line in text.splitlines():
|
|
122
|
+
key, sep, value = line.partition("=")
|
|
123
|
+
if not sep:
|
|
124
|
+
continue
|
|
125
|
+
if key.strip() not in ("version", "version_info"):
|
|
126
|
+
continue
|
|
127
|
+
parts = value.strip().split(".")
|
|
128
|
+
if len(parts) < 2:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
return int(parts[0]), int(parts[1])
|
|
132
|
+
except ValueError:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
99
138
|
def get_venv_site_packages() -> Path | None:
|
|
100
139
|
"""Return the ``site-packages`` directory inside the plugin venv.
|
|
101
140
|
|
|
@@ -12,7 +12,13 @@ from rich.console import Console
|
|
|
12
12
|
from .base_command import BaseCommand
|
|
13
13
|
from .base_group_command import BaseGroupCommand
|
|
14
14
|
from .models import ErrorResult, PluginInfo
|
|
15
|
-
from .
|
|
15
|
+
from .output import print_warning
|
|
16
|
+
from .plugin_env import (
|
|
17
|
+
PLUGIN_VENV,
|
|
18
|
+
get_plugin_state,
|
|
19
|
+
get_venv_python_version,
|
|
20
|
+
get_venv_site_packages,
|
|
21
|
+
)
|
|
16
22
|
from .timing import TimingContext
|
|
17
23
|
|
|
18
24
|
console = Console()
|
|
@@ -139,6 +145,8 @@ class PluginManager:
|
|
|
139
145
|
if site_packages is None:
|
|
140
146
|
return
|
|
141
147
|
|
|
148
|
+
self._warn_if_plugin_venv_python_mismatch()
|
|
149
|
+
|
|
142
150
|
site_packages_str = str(site_packages)
|
|
143
151
|
|
|
144
152
|
# Append to sys.path so modules inside the plugin venv can be imported.
|
|
@@ -186,6 +194,36 @@ class PluginManager:
|
|
|
186
194
|
except Exception as e:
|
|
187
195
|
console.print(f"[red]Error loading plugins from plugin venv:[/red] {e}")
|
|
188
196
|
|
|
197
|
+
def _warn_if_plugin_venv_python_mismatch(self) -> None:
|
|
198
|
+
"""Surface a one-line warning when the plugin venv's Python differs from ours.
|
|
199
|
+
|
|
200
|
+
Triggered when the underlying interpreter is bumped (e.g. ``brew upgrade
|
|
201
|
+
python@3.13`` rebuilding deepctl against a newer Python) without the user
|
|
202
|
+
recreating ``~/.deepctl/plugins/venv/``. Pure-Python plugins keep loading
|
|
203
|
+
via :data:`sys.path` bridging; only C-extension plugins fail to load,
|
|
204
|
+
and they fail with a confusing low-level ImportError. This warning gives
|
|
205
|
+
users a clear remediation before they hit that error.
|
|
206
|
+
|
|
207
|
+
Silent when the venv version can't be determined (no ``pyvenv.cfg``,
|
|
208
|
+
unparseable cfg, etc.) — there's no useful guidance to give.
|
|
209
|
+
"""
|
|
210
|
+
venv_version = get_venv_python_version()
|
|
211
|
+
if venv_version is None:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
running = (sys.version_info.major, sys.version_info.minor)
|
|
215
|
+
if venv_version == running:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
venv_str = f"{venv_version[0]}.{venv_version[1]}"
|
|
219
|
+
running_str = f"{running[0]}.{running[1]}"
|
|
220
|
+
print_warning(
|
|
221
|
+
f"Plugin environment was built with Python {venv_str} but you're "
|
|
222
|
+
f"running Python {running_str}. C-extension plugins may fail to "
|
|
223
|
+
f"load. To rebuild:\n"
|
|
224
|
+
f" rm -rf {PLUGIN_VENV} && dg plugin install <your-plugin>"
|
|
225
|
+
)
|
|
226
|
+
|
|
189
227
|
def _create_click_command(self, command_instance: Any) -> click.Command:
|
|
190
228
|
"""Create a Click command from a BaseCommand instance.
|
|
191
229
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|