ml-dash 0.6.1__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 ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ Pre-configured experiment singleton for ML-Dash SDK.
3
+
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.
7
+
8
+ Usage:
9
+ # First, authenticate
10
+ # $ ml-dash login
11
+
12
+ from ml_dash import dxp
13
+
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()
25
+ """
26
+
27
+ import atexit
28
+ from .experiment import Experiment
29
+
30
+ # Create pre-configured singleton experiment in remote mode
31
+ # Uses default remote server (https://api.dash.ml)
32
+ # Token is auto-loaded from storage when first used
33
+ # If not authenticated, operations will fail with AuthenticationError
34
+ dxp = Experiment(
35
+ name="dxp",
36
+ project="scratch",
37
+ remote="https://api.dash.ml",
38
+ )
39
+
40
+ # Register cleanup handler to complete experiment on Python exit (if still open)
41
+ def _cleanup():
42
+ """Complete the dxp experiment on exit if still open."""
43
+ if dxp._is_open:
44
+ try:
45
+ dxp.run.complete()
46
+ except Exception:
47
+ # Silently ignore errors during cleanup
48
+ pass
49
+
50
+ atexit.register(_cleanup)
51
+
52
+ __all__ = ["dxp"]
ml_dash/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """ML-Dash command-line interface."""
2
+
3
+ import argparse
4
+ import sys
5
+ from typing import Optional, List
6
+
7
+
8
+ def create_parser() -> argparse.ArgumentParser:
9
+ """Create the main CLI argument parser."""
10
+ parser = argparse.ArgumentParser(
11
+ prog="ml-dash",
12
+ description="ML-Dash: ML experiment tracking and data storage CLI",
13
+ formatter_class=argparse.RawDescriptionHelpFormatter,
14
+ )
15
+
16
+ # Add subcommands
17
+ subparsers = parser.add_subparsers(
18
+ dest="command",
19
+ help="Available commands",
20
+ metavar="COMMAND",
21
+ )
22
+
23
+ # Import and add command parsers
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
31
+ upload.add_parser(subparsers)
32
+ download.add_parser(subparsers)
33
+ list_cmd.add_parser(subparsers)
34
+
35
+ return parser
36
+
37
+
38
+ def main(argv: Optional[List[str]] = None) -> int:
39
+ """
40
+ Main CLI entry point.
41
+
42
+ Args:
43
+ argv: Command-line arguments (defaults to sys.argv[1:])
44
+
45
+ Returns:
46
+ Exit code (0 for success, non-zero for error)
47
+ """
48
+ parser = create_parser()
49
+ args = parser.parse_args(argv)
50
+
51
+ # If no command specified, show help
52
+ if args.command is None:
53
+ parser.print_help()
54
+ return 0
55
+
56
+ # Route to command handlers
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":
64
+ from .cli_commands import upload
65
+ return upload.cmd_upload(args)
66
+ elif args.command == "download":
67
+ from .cli_commands import download
68
+ return download.cmd_download(args)
69
+ elif args.command == "list":
70
+ from .cli_commands import list as list_cmd
71
+ return list_cmd.cmd_list(args)
72
+
73
+ # Unknown command (shouldn't happen due to subparsers)
74
+ parser.print_help()
75
+ return 1
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """CLI command modules for ML-Dash."""