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.
@@ -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
- Auto-start module for ML-Dash SDK.
2
+ Pre-configured experiment singleton for ML-Dash SDK.
3
3
 
4
- Provides a pre-configured, auto-started experiment singleton named 'dxp'.
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
- from ml_dash.auto_start import dxp
9
+ # First, authenticate
10
+ # $ ml-dash login
8
11
 
9
- # Ready to use immediately - no need to open/start
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
- # Automatically closed on Python exit
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
- # Create pre-configured singleton experiment
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
- local_path=".ml-dash"
50
+ remote="https://api.dash.ml",
25
51
  )
26
52
 
27
- # Auto-start the experiment on import
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 == "upload":
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":
@@ -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(namespace, project_filter, experiment_filter)
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(namespace, project_filter)
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(namespace)
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(namespace, project['slug'])
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 = _get_or_generate_api_key(args, config)
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 not api_key:
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, namespace, args.project, args.experiment
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")
@@ -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(namespace)
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 for namespace: {namespace}[/yellow]")
90
+ console.print(f"[yellow]No projects found[/yellow]")
94
91
  return 0
95
92
 
96
- console.print(f"\n[bold]Projects for {namespace}[/bold]\n")
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
- namespace, project, status=status_filter
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 > generate from username)
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("--api-key", type=str, help="JWT authentication token")
330
- parser.add_argument("--username", type=str, help="Username for auto-generating API key")
331
- parser.add_argument("--namespace", type=str, help="Namespace slug (defaults to username)")
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,