ml-dash 0.6.0__py3-none-any.whl → 0.6.2__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.
- ml_dash/__init__.py +37 -63
- ml_dash/auth/token_storage.py +267 -226
- ml_dash/auto_start.py +30 -30
- ml_dash/cli.py +16 -2
- ml_dash/cli_commands/api.py +165 -0
- ml_dash/cli_commands/download.py +757 -667
- ml_dash/cli_commands/list.py +146 -13
- ml_dash/cli_commands/login.py +190 -183
- ml_dash/cli_commands/profile.py +92 -0
- ml_dash/cli_commands/upload.py +1291 -1141
- ml_dash/client.py +122 -34
- ml_dash/config.py +119 -119
- ml_dash/experiment.py +1242 -995
- ml_dash/files.py +1051 -340
- ml_dash/log.py +7 -7
- ml_dash/metric.py +359 -100
- ml_dash/params.py +6 -6
- ml_dash/remote_auto_start.py +20 -17
- ml_dash/run.py +231 -0
- ml_dash/snowflake.py +173 -0
- ml_dash/storage.py +1051 -1079
- {ml_dash-0.6.0.dist-info → ml_dash-0.6.2.dist-info}/METADATA +45 -20
- ml_dash-0.6.2.dist-info/RECORD +33 -0
- ml_dash-0.6.0.dist-info/RECORD +0 -29
- {ml_dash-0.6.0.dist-info → ml_dash-0.6.2.dist-info}/WHEEL +0 -0
- {ml_dash-0.6.0.dist-info → ml_dash-0.6.2.dist-info}/entry_points.txt +0 -0
ml_dash/__init__.py
CHANGED
|
@@ -3,83 +3,57 @@ ML-Dash Python SDK
|
|
|
3
3
|
|
|
4
4
|
A simple and flexible SDK for ML experiment metricing and data storage.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
Prefix format: {owner}/{project}/path.../[name]
|
|
7
|
+
- owner: First segment (e.g., your username)
|
|
8
|
+
- project: Second segment (e.g., project name)
|
|
9
|
+
- path: Remaining segments form the folder structure
|
|
10
|
+
- name: Derived from last segment (may be a seed/id)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
dxp.params.set(lr=0.001)
|
|
14
|
-
dxp.log().info("Training started")
|
|
15
|
-
# Auto-completes on exit from with block
|
|
12
|
+
Usage:
|
|
16
13
|
|
|
17
|
-
# Local mode - explicit configuration
|
|
18
14
|
from ml_dash import Experiment
|
|
19
15
|
|
|
16
|
+
# Local mode - explicit configuration
|
|
20
17
|
with Experiment(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
remote="https://api.dash.ml",
|
|
34
|
-
api_key="your-jwt-token"
|
|
35
|
-
) as experiment:
|
|
36
|
-
experiment.log("Training started")
|
|
18
|
+
prefix="ge/my-project/experiments/exp1",
|
|
19
|
+
dash_root=".dash"
|
|
20
|
+
).run as exp:
|
|
21
|
+
exp.log("Training started")
|
|
22
|
+
exp.params.set(lr=0.001)
|
|
23
|
+
exp.metrics("train").log(loss=0.5, step=0)
|
|
24
|
+
|
|
25
|
+
# Default: Remote mode (defaults to https://api.dash.ml)
|
|
26
|
+
with Experiment(prefix="ge/my-project/experiments/exp1").run as exp:
|
|
27
|
+
exp.log("Training started")
|
|
28
|
+
exp.params.set(lr=0.001)
|
|
29
|
+
exp.metrics("train").log(loss=0.5, step=0)
|
|
37
30
|
|
|
38
31
|
# Decorator style
|
|
39
32
|
from ml_dash import ml_dash_experiment
|
|
40
33
|
|
|
41
|
-
@ml_dash_experiment(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
def train_model(experiment):
|
|
46
|
-
experiment.log("Training started")
|
|
34
|
+
@ml_dash_experiment(prefix="ge/my-project/experiments/exp1")
|
|
35
|
+
def train_model(exp):
|
|
36
|
+
exp.log("Training started")
|
|
47
37
|
"""
|
|
48
38
|
|
|
49
|
-
from .experiment import Experiment, ml_dash_experiment, OperationMode, RunManager
|
|
50
39
|
from .client import RemoteClient
|
|
51
|
-
from .
|
|
52
|
-
from .log import
|
|
40
|
+
from .experiment import Experiment, OperationMode, RunManager, ml_dash_experiment
|
|
41
|
+
from .log import LogBuilder, LogLevel
|
|
53
42
|
from .params import ParametersBuilder
|
|
54
|
-
from .
|
|
43
|
+
from .run import RUN
|
|
44
|
+
from .storage import LocalStorage
|
|
55
45
|
|
|
56
|
-
__version__ = "0.
|
|
46
|
+
__version__ = "0.6.2"
|
|
57
47
|
|
|
58
48
|
__all__ = [
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
"Experiment",
|
|
50
|
+
"ml_dash_experiment",
|
|
51
|
+
"OperationMode",
|
|
52
|
+
"RunManager",
|
|
53
|
+
"RemoteClient",
|
|
54
|
+
"LocalStorage",
|
|
55
|
+
"LogLevel",
|
|
56
|
+
"LogBuilder",
|
|
57
|
+
"ParametersBuilder",
|
|
58
|
+
"RUN",
|
|
69
59
|
]
|
|
70
|
-
|
|
71
|
-
# Hidden for now - rdxp (remote auto-start singleton)
|
|
72
|
-
# Will be exposed in a future release
|
|
73
|
-
#
|
|
74
|
-
# # Lazy-load rdxp to avoid auto-connecting to server on package import
|
|
75
|
-
# _rdxp = None
|
|
76
|
-
#
|
|
77
|
-
# def __getattr__(name):
|
|
78
|
-
# """Lazy-load rdxp only when accessed."""
|
|
79
|
-
# if name == "rdxp":
|
|
80
|
-
# global _rdxp
|
|
81
|
-
# if _rdxp is None:
|
|
82
|
-
# from .remote_auto_start import rdxp as _loaded_rdxp
|
|
83
|
-
# _rdxp = _loaded_rdxp
|
|
84
|
-
# return _rdxp
|
|
85
|
-
# raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
ml_dash/auth/token_storage.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
+
from base64 import urlsafe_b64decode
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Optional
|
|
7
8
|
|
|
@@ -9,254 +10,294 @@ from .exceptions import StorageError
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class TokenStorage(ABC):
|
|
12
|
-
|
|
13
|
+
"""Abstract base class for token storage backends."""
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def store(self, key: str, value: str) -> None:
|
|
17
|
+
"""Store a token.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
Args:
|
|
20
|
+
key: Storage key
|
|
21
|
+
value: Token string to store
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def load(self, key: str) -> Optional[str]:
|
|
27
|
+
"""Load a token.
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
Args:
|
|
30
|
+
key: Storage key
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
Returns:
|
|
33
|
+
Token string or None if not found
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def delete(self, key: str) -> None:
|
|
39
|
+
"""Delete a token.
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
Args:
|
|
42
|
+
key: Storage key
|
|
43
|
+
"""
|
|
44
|
+
pass
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
class KeyringStorage(TokenStorage):
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
SERVICE_NAME = "ml-dash"
|
|
50
|
-
|
|
51
|
-
def __init__(self):
|
|
52
|
-
"""Initialize keyring storage."""
|
|
53
|
-
try:
|
|
54
|
-
import keyring
|
|
55
|
-
self.keyring = keyring
|
|
56
|
-
except ImportError:
|
|
57
|
-
raise StorageError(
|
|
58
|
-
"keyring library not installed. "
|
|
59
|
-
"Install with: pip install keyring"
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
def store(self, key: str, value: str) -> None:
|
|
63
|
-
"""Store token in OS keyring."""
|
|
64
|
-
try:
|
|
65
|
-
self.keyring.set_password(self.SERVICE_NAME, key, value)
|
|
66
|
-
except Exception as e:
|
|
67
|
-
raise StorageError(f"Failed to store token in keyring: {e}")
|
|
68
|
-
|
|
69
|
-
def load(self, key: str) -> Optional[str]:
|
|
70
|
-
"""Load token from OS keyring."""
|
|
71
|
-
try:
|
|
72
|
-
return self.keyring.get_password(self.SERVICE_NAME, key)
|
|
73
|
-
except Exception as e:
|
|
74
|
-
raise StorageError(f"Failed to load token from keyring: {e}")
|
|
75
|
-
|
|
76
|
-
def delete(self, key: str) -> None:
|
|
77
|
-
"""Delete token from OS keyring."""
|
|
78
|
-
try:
|
|
79
|
-
self.keyring.delete_password(self.SERVICE_NAME, key)
|
|
80
|
-
except Exception:
|
|
81
|
-
# Silently ignore if key doesn't exist
|
|
82
|
-
pass
|
|
48
|
+
"""OS keyring storage backend (macOS Keychain, Windows Credential Manager, Linux Secret Service)."""
|
|
83
49
|
|
|
50
|
+
SERVICE_NAME = "ml-dash"
|
|
84
51
|
|
|
85
|
-
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
"""Initialize encrypted file storage.
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
config_dir: Configuration directory path
|
|
93
|
-
"""
|
|
94
|
-
self.config_dir = Path(config_dir)
|
|
95
|
-
self.tokens_file = self.config_dir / "tokens.encrypted"
|
|
96
|
-
self.key_file = self.config_dir / "encryption.key"
|
|
97
|
-
|
|
98
|
-
try:
|
|
99
|
-
from cryptography.fernet import Fernet
|
|
100
|
-
self.Fernet = Fernet
|
|
101
|
-
except ImportError:
|
|
102
|
-
raise StorageError(
|
|
103
|
-
"cryptography library not installed. "
|
|
104
|
-
"Install with: pip install cryptography"
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
# Ensure config directory exists
|
|
108
|
-
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
-
|
|
110
|
-
# Generate or load encryption key
|
|
111
|
-
if not self.key_file.exists():
|
|
112
|
-
key = self.Fernet.generate_key()
|
|
113
|
-
self.key_file.write_bytes(key)
|
|
114
|
-
self.key_file.chmod(0o600) # User read/write only
|
|
115
|
-
else:
|
|
116
|
-
key = self.key_file.read_bytes()
|
|
117
|
-
|
|
118
|
-
self.cipher = self.Fernet(key)
|
|
119
|
-
|
|
120
|
-
def _load_all(self) -> dict:
|
|
121
|
-
"""Load all tokens from encrypted file."""
|
|
122
|
-
if not self.tokens_file.exists():
|
|
123
|
-
return {}
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
encrypted = self.tokens_file.read_bytes()
|
|
127
|
-
decrypted = self.cipher.decrypt(encrypted)
|
|
128
|
-
return json.loads(decrypted)
|
|
129
|
-
except Exception as e:
|
|
130
|
-
raise StorageError(f"Failed to decrypt tokens file: {e}")
|
|
131
|
-
|
|
132
|
-
def _save_all(self, data: dict) -> None:
|
|
133
|
-
"""Save all tokens to encrypted file."""
|
|
134
|
-
try:
|
|
135
|
-
plaintext = json.dumps(data).encode()
|
|
136
|
-
encrypted = self.cipher.encrypt(plaintext)
|
|
137
|
-
self.tokens_file.write_bytes(encrypted)
|
|
138
|
-
self.tokens_file.chmod(0o600) # User read/write only
|
|
139
|
-
except Exception as e:
|
|
140
|
-
raise StorageError(f"Failed to encrypt tokens file: {e}")
|
|
141
|
-
|
|
142
|
-
def store(self, key: str, value: str) -> None:
|
|
143
|
-
"""Store token in encrypted file."""
|
|
144
|
-
all_tokens = self._load_all()
|
|
145
|
-
all_tokens[key] = value
|
|
146
|
-
self._save_all(all_tokens)
|
|
147
|
-
|
|
148
|
-
def load(self, key: str) -> Optional[str]:
|
|
149
|
-
"""Load token from encrypted file."""
|
|
150
|
-
all_tokens = self._load_all()
|
|
151
|
-
return all_tokens.get(key)
|
|
152
|
-
|
|
153
|
-
def delete(self, key: str) -> None:
|
|
154
|
-
"""Delete token from encrypted file."""
|
|
155
|
-
all_tokens = self._load_all()
|
|
156
|
-
if key in all_tokens:
|
|
157
|
-
del all_tokens[key]
|
|
158
|
-
self._save_all(all_tokens)
|
|
52
|
+
def __init__(self):
|
|
53
|
+
"""Initialize keyring storage."""
|
|
54
|
+
try:
|
|
55
|
+
import keyring
|
|
159
56
|
|
|
57
|
+
self.keyring = keyring
|
|
58
|
+
except ImportError:
|
|
59
|
+
raise StorageError(
|
|
60
|
+
"keyring library not installed. Install with: pip install keyring"
|
|
61
|
+
)
|
|
160
62
|
|
|
161
|
-
|
|
162
|
-
"""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"""Initialize plaintext file storage.
|
|
168
|
-
|
|
169
|
-
Args:
|
|
170
|
-
config_dir: Configuration directory path
|
|
171
|
-
"""
|
|
172
|
-
self.config_dir = Path(config_dir)
|
|
173
|
-
self.tokens_file = self.config_dir / "tokens.json"
|
|
174
|
-
|
|
175
|
-
# Ensure config directory exists
|
|
176
|
-
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
177
|
-
|
|
178
|
-
# Show security warning on first use
|
|
179
|
-
if not PlaintextFileStorage._warning_shown:
|
|
180
|
-
try:
|
|
181
|
-
from rich.console import Console
|
|
182
|
-
console = Console()
|
|
183
|
-
console.print(
|
|
184
|
-
"\n[bold red]WARNING: Storing tokens in plaintext![/bold red]\n"
|
|
185
|
-
"[yellow]Your authentication tokens are being stored unencrypted.[/yellow]\n"
|
|
186
|
-
"[yellow]This is insecure and only recommended for testing.[/yellow]\n\n"
|
|
187
|
-
"To use secure storage:\n"
|
|
188
|
-
" • Install keyring: pip install keyring\n"
|
|
189
|
-
" • Or encrypted storage will be used automatically\n"
|
|
190
|
-
)
|
|
191
|
-
except ImportError:
|
|
192
|
-
print("WARNING: Storing tokens in plaintext! This is insecure.")
|
|
193
|
-
|
|
194
|
-
PlaintextFileStorage._warning_shown = True
|
|
195
|
-
|
|
196
|
-
def _load_all(self) -> dict:
|
|
197
|
-
"""Load all tokens from file."""
|
|
198
|
-
if not self.tokens_file.exists():
|
|
199
|
-
return {}
|
|
200
|
-
|
|
201
|
-
try:
|
|
202
|
-
with open(self.tokens_file, "r") as f:
|
|
203
|
-
return json.load(f)
|
|
204
|
-
except (json.JSONDecodeError, IOError):
|
|
205
|
-
return {}
|
|
206
|
-
|
|
207
|
-
def _save_all(self, data: dict) -> None:
|
|
208
|
-
"""Save all tokens to file."""
|
|
209
|
-
with open(self.tokens_file, "w") as f:
|
|
210
|
-
json.dump(data, f, indent=2)
|
|
211
|
-
self.tokens_file.chmod(0o600) # User read/write only
|
|
212
|
-
|
|
213
|
-
def store(self, key: str, value: str) -> None:
|
|
214
|
-
"""Store token in plaintext file."""
|
|
215
|
-
all_tokens = self._load_all()
|
|
216
|
-
all_tokens[key] = value
|
|
217
|
-
self._save_all(all_tokens)
|
|
218
|
-
|
|
219
|
-
def load(self, key: str) -> Optional[str]:
|
|
220
|
-
"""Load token from plaintext file."""
|
|
221
|
-
all_tokens = self._load_all()
|
|
222
|
-
return all_tokens.get(key)
|
|
223
|
-
|
|
224
|
-
def delete(self, key: str) -> None:
|
|
225
|
-
"""Delete token from plaintext file."""
|
|
226
|
-
all_tokens = self._load_all()
|
|
227
|
-
if key in all_tokens:
|
|
228
|
-
del all_tokens[key]
|
|
229
|
-
self._save_all(all_tokens)
|
|
63
|
+
def store(self, key: str, value: str) -> None:
|
|
64
|
+
"""Store token in OS keyring."""
|
|
65
|
+
try:
|
|
66
|
+
self.keyring.set_password(self.SERVICE_NAME, key, value)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise StorageError(f"Failed to store token in keyring: {e}")
|
|
230
69
|
|
|
70
|
+
def load(self, key: str) -> Optional[str]:
|
|
71
|
+
"""Load token from OS keyring."""
|
|
72
|
+
try:
|
|
73
|
+
return self.keyring.get_password(self.SERVICE_NAME, key)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
raise StorageError(f"Failed to load token from keyring: {e}")
|
|
231
76
|
|
|
232
|
-
def
|
|
233
|
-
"""
|
|
77
|
+
def delete(self, key: str) -> None:
|
|
78
|
+
"""Delete token from OS keyring."""
|
|
79
|
+
try:
|
|
80
|
+
self.keyring.delete_password(self.SERVICE_NAME, key)
|
|
81
|
+
except Exception:
|
|
82
|
+
# Silently ignore if key doesn't exist
|
|
83
|
+
pass
|
|
234
84
|
|
|
235
|
-
Tries backends in order of security:
|
|
236
|
-
1. KeyringStorage (OS keyring)
|
|
237
|
-
2. EncryptedFileStorage (encrypted file)
|
|
238
|
-
3. PlaintextFileStorage (plaintext file with warning)
|
|
239
85
|
|
|
240
|
-
|
|
241
|
-
|
|
86
|
+
class EncryptedFileStorage(TokenStorage):
|
|
87
|
+
"""Encrypted file storage backend using Fernet symmetric encryption."""
|
|
242
88
|
|
|
243
|
-
|
|
244
|
-
|
|
89
|
+
def __init__(self, config_dir: Path):
|
|
90
|
+
"""Initialize encrypted file storage.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
config_dir: Configuration directory path
|
|
245
94
|
"""
|
|
246
|
-
|
|
247
|
-
|
|
95
|
+
self.config_dir = Path(config_dir)
|
|
96
|
+
self.tokens_file = self.config_dir / "tokens.encrypted"
|
|
97
|
+
self.key_file = self.config_dir / "encryption.key"
|
|
248
98
|
|
|
249
|
-
# Try keyring first
|
|
250
99
|
try:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
100
|
+
from cryptography.fernet import Fernet
|
|
101
|
+
|
|
102
|
+
self.Fernet = Fernet
|
|
103
|
+
except ImportError:
|
|
104
|
+
raise StorageError(
|
|
105
|
+
"cryptography library not installed. Install with: pip install cryptography"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Ensure config directory exists
|
|
109
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
# Generate or load encryption key
|
|
112
|
+
if not self.key_file.exists():
|
|
113
|
+
key = self.Fernet.generate_key()
|
|
114
|
+
self.key_file.write_bytes(key)
|
|
115
|
+
self.key_file.chmod(0o600) # User read/write only
|
|
116
|
+
else:
|
|
117
|
+
key = self.key_file.read_bytes()
|
|
118
|
+
|
|
119
|
+
self.cipher = self.Fernet(key)
|
|
120
|
+
|
|
121
|
+
def _load_all(self) -> dict:
|
|
122
|
+
"""Load all tokens from encrypted file."""
|
|
123
|
+
if not self.tokens_file.exists():
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
encrypted = self.tokens_file.read_bytes()
|
|
128
|
+
decrypted = self.cipher.decrypt(encrypted)
|
|
129
|
+
return json.loads(decrypted)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise StorageError(f"Failed to decrypt tokens file: {e}")
|
|
132
|
+
|
|
133
|
+
def _save_all(self, data: dict) -> None:
|
|
134
|
+
"""Save all tokens to encrypted file."""
|
|
135
|
+
try:
|
|
136
|
+
plaintext = json.dumps(data).encode()
|
|
137
|
+
encrypted = self.cipher.encrypt(plaintext)
|
|
138
|
+
self.tokens_file.write_bytes(encrypted)
|
|
139
|
+
self.tokens_file.chmod(0o600) # User read/write only
|
|
140
|
+
except Exception as e:
|
|
141
|
+
raise StorageError(f"Failed to encrypt tokens file: {e}")
|
|
142
|
+
|
|
143
|
+
def store(self, key: str, value: str) -> None:
|
|
144
|
+
"""Store token in encrypted file."""
|
|
145
|
+
all_tokens = self._load_all()
|
|
146
|
+
all_tokens[key] = value
|
|
147
|
+
self._save_all(all_tokens)
|
|
148
|
+
|
|
149
|
+
def load(self, key: str) -> Optional[str]:
|
|
150
|
+
"""Load token from encrypted file."""
|
|
151
|
+
all_tokens = self._load_all()
|
|
152
|
+
return all_tokens.get(key)
|
|
153
|
+
|
|
154
|
+
def delete(self, key: str) -> None:
|
|
155
|
+
"""Delete token from encrypted file."""
|
|
156
|
+
all_tokens = self._load_all()
|
|
157
|
+
if key in all_tokens:
|
|
158
|
+
del all_tokens[key]
|
|
159
|
+
self._save_all(all_tokens)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PlaintextFileStorage(TokenStorage):
|
|
163
|
+
"""Plaintext file storage backend (INSECURE - only for testing/fallback)."""
|
|
164
|
+
|
|
165
|
+
_warning_shown = False
|
|
166
|
+
|
|
167
|
+
def __init__(self, config_dir: Path):
|
|
168
|
+
"""Initialize plaintext file storage.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
config_dir: Configuration directory path
|
|
172
|
+
"""
|
|
173
|
+
self.config_dir = Path(config_dir)
|
|
174
|
+
self.tokens_file = self.config_dir / "tokens.json"
|
|
175
|
+
|
|
176
|
+
# Ensure config directory exists
|
|
177
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
# Show security warning on first use
|
|
180
|
+
if not PlaintextFileStorage._warning_shown:
|
|
181
|
+
try:
|
|
182
|
+
from rich.console import Console
|
|
183
|
+
|
|
184
|
+
console = Console()
|
|
185
|
+
console.print(
|
|
186
|
+
"\n[bold red]WARNING: Storing tokens in plaintext![/bold red]\n"
|
|
187
|
+
"[yellow]Your authentication tokens are being stored unencrypted.[/yellow]\n"
|
|
188
|
+
"[yellow]This is insecure and only recommended for testing.[/yellow]\n\n"
|
|
189
|
+
"To use secure storage:\n"
|
|
190
|
+
" • Install keyring: pip install keyring\n"
|
|
191
|
+
" • Or encrypted storage will be used automatically\n"
|
|
192
|
+
)
|
|
193
|
+
except ImportError:
|
|
194
|
+
print("WARNING: Storing tokens in plaintext! This is insecure.")
|
|
195
|
+
|
|
196
|
+
PlaintextFileStorage._warning_shown = True
|
|
197
|
+
|
|
198
|
+
def _load_all(self) -> dict:
|
|
199
|
+
"""Load all tokens from file."""
|
|
200
|
+
if not self.tokens_file.exists():
|
|
201
|
+
return {}
|
|
254
202
|
|
|
255
|
-
# Try encrypted file storage
|
|
256
203
|
try:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
204
|
+
with open(self.tokens_file, "r") as f:
|
|
205
|
+
return json.load(f)
|
|
206
|
+
except (json.JSONDecodeError, IOError):
|
|
207
|
+
return {}
|
|
208
|
+
|
|
209
|
+
def _save_all(self, data: dict) -> None:
|
|
210
|
+
"""Save all tokens to file."""
|
|
211
|
+
with open(self.tokens_file, "w") as f:
|
|
212
|
+
json.dump(data, f, indent=2)
|
|
213
|
+
self.tokens_file.chmod(0o600) # User read/write only
|
|
214
|
+
|
|
215
|
+
def store(self, key: str, value: str) -> None:
|
|
216
|
+
"""Store token in plaintext file."""
|
|
217
|
+
all_tokens = self._load_all()
|
|
218
|
+
all_tokens[key] = value
|
|
219
|
+
self._save_all(all_tokens)
|
|
220
|
+
|
|
221
|
+
def load(self, key: str) -> Optional[str]:
|
|
222
|
+
"""Load token from plaintext file."""
|
|
223
|
+
all_tokens = self._load_all()
|
|
224
|
+
return all_tokens.get(key)
|
|
225
|
+
|
|
226
|
+
def delete(self, key: str) -> None:
|
|
227
|
+
"""Delete token from plaintext file."""
|
|
228
|
+
all_tokens = self._load_all()
|
|
229
|
+
if key in all_tokens:
|
|
230
|
+
del all_tokens[key]
|
|
231
|
+
self._save_all(all_tokens)
|
|
260
232
|
|
|
261
|
-
|
|
262
|
-
|
|
233
|
+
|
|
234
|
+
def get_token_storage(config_dir: Optional[Path] = None) -> TokenStorage:
|
|
235
|
+
"""Auto-detect and return appropriate storage backend.
|
|
236
|
+
|
|
237
|
+
Tries backends in order of security:
|
|
238
|
+
1. KeyringStorage (OS keyring)
|
|
239
|
+
2. EncryptedFileStorage (encrypted file)
|
|
240
|
+
3. PlaintextFileStorage (plaintext file with warning)
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
config_dir: Configuration directory (defaults to ~/.dash)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
TokenStorage instance
|
|
247
|
+
"""
|
|
248
|
+
if config_dir is None:
|
|
249
|
+
config_dir = Path.home() / ".dash"
|
|
250
|
+
|
|
251
|
+
# Try keyring first
|
|
252
|
+
try:
|
|
253
|
+
return KeyringStorage()
|
|
254
|
+
except (ImportError, StorageError):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
# Try encrypted file storage
|
|
258
|
+
try:
|
|
259
|
+
return EncryptedFileStorage(config_dir)
|
|
260
|
+
except (ImportError, StorageError):
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# Fallback to plaintext (with warning)
|
|
264
|
+
return PlaintextFileStorage(config_dir)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def decode_jwt_payload(token: str) -> dict:
|
|
268
|
+
"""Decode JWT payload without verification (for display only).
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
token: JWT token string
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Decoded payload dict
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
# JWT format: header.payload.signature
|
|
278
|
+
parts = token.split(".")
|
|
279
|
+
if len(parts) != 3:
|
|
280
|
+
return {}
|
|
281
|
+
|
|
282
|
+
# Decode payload (second part)
|
|
283
|
+
payload = parts[1]
|
|
284
|
+
# Add padding if needed
|
|
285
|
+
padding = 4 - len(payload) % 4
|
|
286
|
+
if padding != 4:
|
|
287
|
+
payload += "=" * padding
|
|
288
|
+
|
|
289
|
+
decoded = urlsafe_b64decode(payload)
|
|
290
|
+
return json.loads(decoded)
|
|
291
|
+
except Exception:
|
|
292
|
+
return {}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_jwt_user():
|
|
296
|
+
# Load token
|
|
297
|
+
storage = get_token_storage()
|
|
298
|
+
token = storage.load("ml-dash-token")
|
|
299
|
+
|
|
300
|
+
if token:
|
|
301
|
+
user = decode_jwt_payload(token)
|
|
302
|
+
return user
|
|
303
|
+
return None
|