ml-dash 0.5.9__py3-none-any.whl → 0.6.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.
- ml_dash/__init__.py +35 -9
- ml_dash/auth/__init__.py +51 -0
- ml_dash/auth/constants.py +10 -0
- ml_dash/auth/device_flow.py +237 -0
- ml_dash/auth/device_secret.py +49 -0
- ml_dash/auth/exceptions.py +31 -0
- ml_dash/auth/token_storage.py +262 -0
- ml_dash/auto_start.py +37 -14
- ml_dash/cli.py +14 -2
- ml_dash/cli_commands/download.py +10 -38
- ml_dash/cli_commands/list.py +10 -34
- ml_dash/cli_commands/login.py +225 -0
- ml_dash/cli_commands/logout.py +54 -0
- ml_dash/cli_commands/upload.py +3 -53
- ml_dash/client.py +67 -34
- ml_dash/config.py +15 -1
- ml_dash/experiment.py +151 -55
- ml_dash/files.py +97 -0
- ml_dash/metric.py +192 -3
- ml_dash/params.py +92 -3
- ml_dash/remote_auto_start.py +55 -0
- ml_dash/storage.py +31 -19
- {ml_dash-0.5.9.dist-info → ml_dash-0.6.0.dist-info}/METADATA +5 -1
- ml_dash-0.6.0.dist-info/RECORD +29 -0
- ml_dash-0.5.9.dist-info/RECORD +0 -20
- {ml_dash-0.5.9.dist-info → ml_dash-0.6.0.dist-info}/WHEEL +0 -0
- {ml_dash-0.5.9.dist-info → ml_dash-0.6.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Token storage backends for ml-dash authentication."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .exceptions import StorageError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TokenStorage(ABC):
|
|
12
|
+
"""Abstract base class for token storage backends."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def store(self, key: str, value: str) -> None:
|
|
16
|
+
"""Store a token.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
key: Storage key
|
|
20
|
+
value: Token string to store
|
|
21
|
+
"""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def load(self, key: str) -> Optional[str]:
|
|
26
|
+
"""Load a token.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
key: Storage key
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Token string or None if not found
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def delete(self, key: str) -> None:
|
|
38
|
+
"""Delete a token.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
key: Storage key
|
|
42
|
+
"""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class KeyringStorage(TokenStorage):
|
|
47
|
+
"""OS keyring storage backend (macOS Keychain, Windows Credential Manager, Linux Secret Service)."""
|
|
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
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EncryptedFileStorage(TokenStorage):
|
|
86
|
+
"""Encrypted file storage backend using Fernet symmetric encryption."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, config_dir: Path):
|
|
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)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PlaintextFileStorage(TokenStorage):
|
|
162
|
+
"""Plaintext file storage backend (INSECURE - only for testing/fallback)."""
|
|
163
|
+
|
|
164
|
+
_warning_shown = False
|
|
165
|
+
|
|
166
|
+
def __init__(self, config_dir: Path):
|
|
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)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_token_storage(config_dir: Optional[Path] = None) -> TokenStorage:
|
|
233
|
+
"""Auto-detect and return appropriate storage backend.
|
|
234
|
+
|
|
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
|
+
|
|
240
|
+
Args:
|
|
241
|
+
config_dir: Configuration directory (defaults to ~/.ml-dash)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
TokenStorage instance
|
|
245
|
+
"""
|
|
246
|
+
if config_dir is None:
|
|
247
|
+
config_dir = Path.home() / ".ml-dash"
|
|
248
|
+
|
|
249
|
+
# Try keyring first
|
|
250
|
+
try:
|
|
251
|
+
return KeyringStorage()
|
|
252
|
+
except (ImportError, StorageError):
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
# Try encrypted file storage
|
|
256
|
+
try:
|
|
257
|
+
return EncryptedFileStorage(config_dir)
|
|
258
|
+
except (ImportError, StorageError):
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
# Fallback to plaintext (with warning)
|
|
262
|
+
return PlaintextFileStorage(config_dir)
|
ml_dash/auto_start.py
CHANGED
|
@@ -1,33 +1,56 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Pre-configured experiment singleton for ML-Dash SDK.
|
|
3
3
|
|
|
4
|
-
Provides a pre-configured
|
|
4
|
+
Provides a pre-configured experiment singleton named 'dxp' in remote mode.
|
|
5
|
+
Requires authentication - run 'ml-dash login' first.
|
|
6
|
+
Requires manual start using 'with' statement or explicit start() call.
|
|
5
7
|
|
|
6
8
|
Usage:
|
|
7
|
-
|
|
9
|
+
# First, authenticate
|
|
10
|
+
# $ ml-dash login
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
dxp.log("Hello from dxp!")
|
|
11
|
-
dxp.params.set(lr=0.001)
|
|
12
|
-
dxp.metrics("loss").append(step=0, value=0.5)
|
|
12
|
+
from ml_dash import dxp
|
|
13
13
|
|
|
14
|
-
#
|
|
14
|
+
# Use with statement (recommended)
|
|
15
|
+
with dxp.run:
|
|
16
|
+
dxp.log().info("Hello from dxp!")
|
|
17
|
+
dxp.params.set(lr=0.001)
|
|
18
|
+
dxp.metrics("loss").append(step=0, value=0.5)
|
|
19
|
+
# Automatically completes on exit from with block
|
|
20
|
+
|
|
21
|
+
# Or start/complete manually
|
|
22
|
+
dxp.run.start()
|
|
23
|
+
dxp.log().info("Training...")
|
|
24
|
+
dxp.run.complete()
|
|
15
25
|
"""
|
|
16
26
|
|
|
17
27
|
import atexit
|
|
18
28
|
from .experiment import Experiment
|
|
29
|
+
from .auth.token_storage import get_token_storage
|
|
30
|
+
from .auth.exceptions import AuthenticationError
|
|
31
|
+
|
|
32
|
+
# Check if user is authenticated
|
|
33
|
+
_storage = get_token_storage()
|
|
34
|
+
_token = _storage.load("ml-dash-token")
|
|
19
35
|
|
|
20
|
-
|
|
36
|
+
if not _token:
|
|
37
|
+
raise AuthenticationError(
|
|
38
|
+
"Not authenticated. Please run 'ml-dash login' to authenticate before using dxp.\n\n"
|
|
39
|
+
"To login:\n"
|
|
40
|
+
" ml-dash login\n\n"
|
|
41
|
+
"Or use Experiment() with explicit api_key parameter."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Create pre-configured singleton experiment in remote mode
|
|
45
|
+
# Uses default remote server (https://api.dash.ml)
|
|
46
|
+
# Token is auto-loaded from storage
|
|
21
47
|
dxp = Experiment(
|
|
22
48
|
name="dxp",
|
|
23
49
|
project="scratch",
|
|
24
|
-
|
|
50
|
+
remote="https://api.dash.ml",
|
|
25
51
|
)
|
|
26
52
|
|
|
27
|
-
#
|
|
28
|
-
dxp.run.start()
|
|
29
|
-
|
|
30
|
-
# Register cleanup handler to complete experiment on Python exit
|
|
53
|
+
# Register cleanup handler to complete experiment on Python exit (if still open)
|
|
31
54
|
def _cleanup():
|
|
32
55
|
"""Complete the dxp experiment on exit if still open."""
|
|
33
56
|
if dxp._is_open:
|
ml_dash/cli.py
CHANGED
|
@@ -21,7 +21,13 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
# Import and add command parsers
|
|
24
|
-
from .cli_commands import upload, download, list as list_cmd
|
|
24
|
+
from .cli_commands import upload, download, list as list_cmd, login, logout
|
|
25
|
+
|
|
26
|
+
# Authentication commands
|
|
27
|
+
login.add_parser(subparsers)
|
|
28
|
+
logout.add_parser(subparsers)
|
|
29
|
+
|
|
30
|
+
# Data commands
|
|
25
31
|
upload.add_parser(subparsers)
|
|
26
32
|
download.add_parser(subparsers)
|
|
27
33
|
list_cmd.add_parser(subparsers)
|
|
@@ -48,7 +54,13 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
48
54
|
return 0
|
|
49
55
|
|
|
50
56
|
# Route to command handlers
|
|
51
|
-
if args.command == "
|
|
57
|
+
if args.command == "login":
|
|
58
|
+
from .cli_commands import login
|
|
59
|
+
return login.cmd_login(args)
|
|
60
|
+
elif args.command == "logout":
|
|
61
|
+
from .cli_commands import logout
|
|
62
|
+
return logout.cmd_logout(args)
|
|
63
|
+
elif args.command == "upload":
|
|
52
64
|
from .cli_commands import upload
|
|
53
65
|
return upload.cmd_upload(args)
|
|
54
66
|
elif args.command == "download":
|
ml_dash/cli_commands/download.py
CHANGED
|
@@ -52,7 +52,6 @@ class DownloadState:
|
|
|
52
52
|
"""State for resuming interrupted downloads."""
|
|
53
53
|
remote_url: str
|
|
54
54
|
local_path: str
|
|
55
|
-
namespace: str
|
|
56
55
|
completed_experiments: List[str] = field(default_factory=list)
|
|
57
56
|
failed_experiments: List[str] = field(default_factory=list)
|
|
58
57
|
in_progress_experiment: Optional[str] = None
|
|
@@ -141,7 +140,6 @@ def _experiment_from_graphql(graphql_data: Dict[str, Any]) -> ExperimentInfo:
|
|
|
141
140
|
|
|
142
141
|
def discover_experiments(
|
|
143
142
|
remote_client: RemoteClient,
|
|
144
|
-
namespace: str,
|
|
145
143
|
project_filter: Optional[str] = None,
|
|
146
144
|
experiment_filter: Optional[str] = None,
|
|
147
145
|
) -> List[ExperimentInfo]:
|
|
@@ -150,7 +148,6 @@ def discover_experiments(
|
|
|
150
148
|
|
|
151
149
|
Args:
|
|
152
150
|
remote_client: Remote API client
|
|
153
|
-
namespace: Namespace slug
|
|
154
151
|
project_filter: Optional project slug filter
|
|
155
152
|
experiment_filter: Optional experiment name filter
|
|
156
153
|
|
|
@@ -159,38 +156,26 @@ def discover_experiments(
|
|
|
159
156
|
"""
|
|
160
157
|
# Specific experiment requested
|
|
161
158
|
if project_filter and experiment_filter:
|
|
162
|
-
exp_data = remote_client.get_experiment_graphql(
|
|
159
|
+
exp_data = remote_client.get_experiment_graphql(project_filter, experiment_filter)
|
|
163
160
|
if exp_data:
|
|
164
161
|
return [_experiment_from_graphql(exp_data)]
|
|
165
162
|
return []
|
|
166
163
|
|
|
167
164
|
# Project filter - get all experiments in project
|
|
168
165
|
if project_filter:
|
|
169
|
-
experiments_data = remote_client.list_experiments_graphql(
|
|
166
|
+
experiments_data = remote_client.list_experiments_graphql(project_filter)
|
|
170
167
|
return [_experiment_from_graphql(exp) for exp in experiments_data]
|
|
171
168
|
|
|
172
169
|
# No filter - get all projects and their experiments
|
|
173
|
-
projects = remote_client.list_projects_graphql(
|
|
170
|
+
projects = remote_client.list_projects_graphql()
|
|
174
171
|
all_experiments = []
|
|
175
172
|
for project in projects:
|
|
176
|
-
experiments_data = remote_client.list_experiments_graphql(
|
|
173
|
+
experiments_data = remote_client.list_experiments_graphql(project['slug'])
|
|
177
174
|
all_experiments.extend([_experiment_from_graphql(exp) for exp in experiments_data])
|
|
178
175
|
|
|
179
176
|
return all_experiments
|
|
180
177
|
|
|
181
178
|
|
|
182
|
-
def _get_or_generate_api_key(args: argparse.Namespace, config: Config) -> str:
|
|
183
|
-
"""Get API key from args, config, or generate from username."""
|
|
184
|
-
if args.api_key:
|
|
185
|
-
return args.api_key
|
|
186
|
-
if config.api_key:
|
|
187
|
-
return config.api_key
|
|
188
|
-
if args.username:
|
|
189
|
-
from ..cli_commands.upload import generate_api_key_from_username
|
|
190
|
-
return generate_api_key_from_username(args.username)
|
|
191
|
-
return ""
|
|
192
|
-
|
|
193
|
-
|
|
194
179
|
# ============================================================================
|
|
195
180
|
# Experiment Downloader
|
|
196
181
|
# ============================================================================
|
|
@@ -577,23 +562,14 @@ def cmd_download(args: argparse.Namespace) -> int:
|
|
|
577
562
|
# Load configuration
|
|
578
563
|
config = Config()
|
|
579
564
|
remote_url = args.remote or config.remote_url
|
|
580
|
-
api_key =
|
|
581
|
-
namespace = args.namespace or args.username
|
|
565
|
+
api_key = args.api_key or config.api_key # RemoteClient will auto-load if None
|
|
582
566
|
|
|
583
567
|
# Validate inputs
|
|
584
568
|
if not remote_url:
|
|
585
569
|
console.print("[red]Error:[/red] --remote is required (or set in config)")
|
|
586
570
|
return 1
|
|
587
571
|
|
|
588
|
-
if
|
|
589
|
-
console.print("[red]Error:[/red] --api-key or --username is required")
|
|
590
|
-
return 1
|
|
591
|
-
|
|
592
|
-
if not namespace:
|
|
593
|
-
console.print("[red]Error:[/red] --namespace or --username is required")
|
|
594
|
-
return 1
|
|
595
|
-
|
|
596
|
-
# Initialize clients
|
|
572
|
+
# Initialize clients (RemoteClient will auto-load token if api_key is None)
|
|
597
573
|
remote_client = RemoteClient(base_url=remote_url, api_key=api_key)
|
|
598
574
|
local_storage = LocalStorage(root_path=Path(args.path))
|
|
599
575
|
|
|
@@ -607,21 +583,19 @@ def cmd_download(args: argparse.Namespace) -> int:
|
|
|
607
583
|
console.print("[yellow]No previous state found, starting fresh[/yellow]")
|
|
608
584
|
state = DownloadState(
|
|
609
585
|
remote_url=remote_url,
|
|
610
|
-
local_path=str(args.path)
|
|
611
|
-
namespace=namespace
|
|
586
|
+
local_path=str(args.path)
|
|
612
587
|
)
|
|
613
588
|
else:
|
|
614
589
|
state = DownloadState(
|
|
615
590
|
remote_url=remote_url,
|
|
616
|
-
local_path=str(args.path)
|
|
617
|
-
namespace=namespace
|
|
591
|
+
local_path=str(args.path)
|
|
618
592
|
)
|
|
619
593
|
|
|
620
594
|
# Discover experiments
|
|
621
595
|
console.print("[bold]Discovering experiments on remote server...[/bold]")
|
|
622
596
|
try:
|
|
623
597
|
experiments = discover_experiments(
|
|
624
|
-
remote_client,
|
|
598
|
+
remote_client, args.project, args.experiment
|
|
625
599
|
)
|
|
626
600
|
except Exception as e:
|
|
627
601
|
console.print(f"[red]Failed to discover experiments: {e}[/red]")
|
|
@@ -751,13 +725,11 @@ def add_parser(subparsers):
|
|
|
751
725
|
|
|
752
726
|
# Remote configuration
|
|
753
727
|
parser.add_argument("--remote", help="Remote server URL")
|
|
754
|
-
parser.add_argument("--api-key", help="JWT authentication token")
|
|
755
|
-
parser.add_argument("--username", help="Username for auto-generating API key")
|
|
728
|
+
parser.add_argument("--api-key", help="JWT authentication token (optional - auto-loads from 'ml-dash login')")
|
|
756
729
|
|
|
757
730
|
# Scope control
|
|
758
731
|
parser.add_argument("--project", help="Download only this project")
|
|
759
732
|
parser.add_argument("--experiment", help="Download specific experiment (requires --project)")
|
|
760
|
-
parser.add_argument("--namespace", help="Namespace slug (defaults to username)")
|
|
761
733
|
|
|
762
734
|
# Data filtering
|
|
763
735
|
parser.add_argument("--skip-logs", action="store_true", help="Don't download logs")
|
ml_dash/cli_commands/list.py
CHANGED
|
@@ -58,7 +58,6 @@ def _get_status_style(status: str) -> str:
|
|
|
58
58
|
|
|
59
59
|
def list_projects(
|
|
60
60
|
remote_client: RemoteClient,
|
|
61
|
-
namespace: str,
|
|
62
61
|
output_json: bool = False,
|
|
63
62
|
verbose: bool = False
|
|
64
63
|
) -> int:
|
|
@@ -67,7 +66,6 @@ def list_projects(
|
|
|
67
66
|
|
|
68
67
|
Args:
|
|
69
68
|
remote_client: Remote API client
|
|
70
|
-
namespace: Namespace slug
|
|
71
69
|
output_json: Output as JSON
|
|
72
70
|
verbose: Show verbose output
|
|
73
71
|
|
|
@@ -76,12 +74,11 @@ def list_projects(
|
|
|
76
74
|
"""
|
|
77
75
|
try:
|
|
78
76
|
# Get projects via GraphQL
|
|
79
|
-
projects = remote_client.list_projects_graphql(
|
|
77
|
+
projects = remote_client.list_projects_graphql()
|
|
80
78
|
|
|
81
79
|
if output_json:
|
|
82
80
|
# JSON output
|
|
83
81
|
output = {
|
|
84
|
-
"namespace": namespace,
|
|
85
82
|
"projects": projects,
|
|
86
83
|
"count": len(projects)
|
|
87
84
|
}
|
|
@@ -90,10 +87,10 @@ def list_projects(
|
|
|
90
87
|
|
|
91
88
|
# Human-readable output
|
|
92
89
|
if not projects:
|
|
93
|
-
console.print(f"[yellow]No projects found
|
|
90
|
+
console.print(f"[yellow]No projects found[/yellow]")
|
|
94
91
|
return 0
|
|
95
92
|
|
|
96
|
-
console.print(f"\n[bold]Projects
|
|
93
|
+
console.print(f"\n[bold]Projects[/bold]\n")
|
|
97
94
|
|
|
98
95
|
# Create table
|
|
99
96
|
table = Table(box=box.ROUNDED)
|
|
@@ -128,7 +125,6 @@ def list_projects(
|
|
|
128
125
|
|
|
129
126
|
def list_experiments(
|
|
130
127
|
remote_client: RemoteClient,
|
|
131
|
-
namespace: str,
|
|
132
128
|
project: str,
|
|
133
129
|
status_filter: Optional[str] = None,
|
|
134
130
|
tags_filter: Optional[List[str]] = None,
|
|
@@ -141,7 +137,6 @@ def list_experiments(
|
|
|
141
137
|
|
|
142
138
|
Args:
|
|
143
139
|
remote_client: Remote API client
|
|
144
|
-
namespace: Namespace slug
|
|
145
140
|
project: Project slug
|
|
146
141
|
status_filter: Filter by status (COMPLETED, RUNNING, FAILED, ARCHIVED)
|
|
147
142
|
tags_filter: Filter by tags
|
|
@@ -155,7 +150,7 @@ def list_experiments(
|
|
|
155
150
|
try:
|
|
156
151
|
# Get experiments via GraphQL
|
|
157
152
|
experiments = remote_client.list_experiments_graphql(
|
|
158
|
-
|
|
153
|
+
project, status=status_filter
|
|
159
154
|
)
|
|
160
155
|
|
|
161
156
|
# Filter by tags if specified
|
|
@@ -168,7 +163,6 @@ def list_experiments(
|
|
|
168
163
|
if output_json:
|
|
169
164
|
# JSON output
|
|
170
165
|
output = {
|
|
171
|
-
"namespace": namespace,
|
|
172
166
|
"project": project,
|
|
173
167
|
"experiments": experiments,
|
|
174
168
|
"count": len(experiments)
|
|
@@ -263,26 +257,9 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
263
257
|
console.print("[red]Error:[/red] --remote URL is required (or set in config)")
|
|
264
258
|
return 1
|
|
265
259
|
|
|
266
|
-
# Get API key (command line > config >
|
|
260
|
+
# Get API key (command line > config > auto-loaded from storage)
|
|
267
261
|
api_key = args.api_key or config.api_key
|
|
268
262
|
|
|
269
|
-
# If no API key, try to generate from username
|
|
270
|
-
if not api_key:
|
|
271
|
-
if args.username:
|
|
272
|
-
from .upload import generate_api_key_from_username
|
|
273
|
-
api_key = generate_api_key_from_username(args.username)
|
|
274
|
-
if args.verbose:
|
|
275
|
-
console.print(f"[dim]Generated API key from username: {args.username}[/dim]")
|
|
276
|
-
else:
|
|
277
|
-
console.print("[red]Error:[/red] --api-key or --username is required")
|
|
278
|
-
return 1
|
|
279
|
-
|
|
280
|
-
# Get namespace (defaults to username or config)
|
|
281
|
-
namespace = args.namespace or args.username or config.namespace
|
|
282
|
-
if not namespace:
|
|
283
|
-
console.print("[red]Error:[/red] --namespace or --username is required")
|
|
284
|
-
return 1
|
|
285
|
-
|
|
286
263
|
# Create remote client
|
|
287
264
|
try:
|
|
288
265
|
remote_client = RemoteClient(base_url=remote_url, api_key=api_key)
|
|
@@ -299,7 +276,6 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
299
276
|
|
|
300
277
|
return list_experiments(
|
|
301
278
|
remote_client=remote_client,
|
|
302
|
-
namespace=namespace,
|
|
303
279
|
project=args.project,
|
|
304
280
|
status_filter=args.status,
|
|
305
281
|
tags_filter=tags_filter,
|
|
@@ -310,7 +286,6 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
310
286
|
else:
|
|
311
287
|
return list_projects(
|
|
312
288
|
remote_client=remote_client,
|
|
313
|
-
namespace=namespace,
|
|
314
289
|
output_json=args.json,
|
|
315
290
|
verbose=args.verbose
|
|
316
291
|
)
|
|
@@ -326,10 +301,11 @@ def add_parser(subparsers) -> None:
|
|
|
326
301
|
|
|
327
302
|
# Remote configuration
|
|
328
303
|
parser.add_argument("--remote", type=str, help="Remote server URL")
|
|
329
|
-
parser.add_argument(
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
304
|
+
parser.add_argument(
|
|
305
|
+
"--api-key",
|
|
306
|
+
type=str,
|
|
307
|
+
help="JWT authentication token (auto-loaded from storage if not provided)"
|
|
308
|
+
)
|
|
333
309
|
# Filtering options
|
|
334
310
|
parser.add_argument("--project", type=str, help="List experiments in this project")
|
|
335
311
|
parser.add_argument("--status", type=str,
|