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,319 @@
1
+ """
2
+ List command for ML-Dash CLI.
3
+
4
+ Allows users to discover projects and experiments on the remote server.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ from typing import Optional, List, Dict, Any
10
+ from datetime import datetime
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich import box
14
+
15
+ from ..client import RemoteClient
16
+ from ..config import Config
17
+
18
+ console = Console()
19
+
20
+
21
+ def _format_timestamp(iso_timestamp: str) -> str:
22
+ """Format ISO timestamp as human-readable relative time."""
23
+ try:
24
+ dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
25
+ now = datetime.now(dt.tzinfo)
26
+ diff = now - dt
27
+
28
+ if diff.days > 365:
29
+ years = diff.days // 365
30
+ return f"{years} year{'s' if years > 1 else ''} ago"
31
+ elif diff.days > 30:
32
+ months = diff.days // 30
33
+ return f"{months} month{'s' if months > 1 else ''} ago"
34
+ elif diff.days > 0:
35
+ return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
36
+ elif diff.seconds > 3600:
37
+ hours = diff.seconds // 3600
38
+ return f"{hours} hour{'s' if hours > 1 else ''} ago"
39
+ elif diff.seconds > 60:
40
+ minutes = diff.seconds // 60
41
+ return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
42
+ else:
43
+ return "just now"
44
+ except:
45
+ return iso_timestamp
46
+
47
+
48
+ def _get_status_style(status: str) -> str:
49
+ """Get rich style for status."""
50
+ status_styles = {
51
+ "COMPLETED": "green",
52
+ "RUNNING": "yellow",
53
+ "FAILED": "red",
54
+ "ARCHIVED": "dim",
55
+ }
56
+ return status_styles.get(status, "white")
57
+
58
+
59
+ def list_projects(
60
+ remote_client: RemoteClient,
61
+ output_json: bool = False,
62
+ verbose: bool = False
63
+ ) -> int:
64
+ """
65
+ List all projects for the user.
66
+
67
+ Args:
68
+ remote_client: Remote API client
69
+ output_json: Output as JSON
70
+ verbose: Show verbose output
71
+
72
+ Returns:
73
+ Exit code (0 for success, 1 for error)
74
+ """
75
+ try:
76
+ # Get projects via GraphQL
77
+ projects = remote_client.list_projects_graphql()
78
+
79
+ if output_json:
80
+ # JSON output
81
+ output = {
82
+ "projects": projects,
83
+ "count": len(projects)
84
+ }
85
+ console.print(json.dumps(output, indent=2))
86
+ return 0
87
+
88
+ # Human-readable output
89
+ if not projects:
90
+ console.print(f"[yellow]No projects found[/yellow]")
91
+ return 0
92
+
93
+ console.print(f"\n[bold]Projects[/bold]\n")
94
+
95
+ # Create table
96
+ table = Table(box=box.ROUNDED)
97
+ table.add_column("Project", style="cyan", no_wrap=True)
98
+ table.add_column("Experiments", justify="right")
99
+ table.add_column("Description", style="dim")
100
+
101
+ for project in projects:
102
+ exp_count = project.get('experimentCount', 0)
103
+ description = project.get('description', '') or ''
104
+ if len(description) > 50:
105
+ description = description[:47] + "..."
106
+
107
+ table.add_row(
108
+ project['slug'],
109
+ str(exp_count),
110
+ description
111
+ )
112
+
113
+ console.print(table)
114
+ console.print(f"\n[dim]Total: {len(projects)} project(s)[/dim]\n")
115
+
116
+ return 0
117
+
118
+ except Exception as e:
119
+ console.print(f"[red]Error listing projects:[/red] {e}")
120
+ if verbose:
121
+ import traceback
122
+ console.print(traceback.format_exc())
123
+ return 1
124
+
125
+
126
+ def list_experiments(
127
+ remote_client: RemoteClient,
128
+ project: str,
129
+ status_filter: Optional[str] = None,
130
+ tags_filter: Optional[List[str]] = None,
131
+ output_json: bool = False,
132
+ detailed: bool = False,
133
+ verbose: bool = False
134
+ ) -> int:
135
+ """
136
+ List experiments in a project.
137
+
138
+ Args:
139
+ remote_client: Remote API client
140
+ project: Project slug
141
+ status_filter: Filter by status (COMPLETED, RUNNING, FAILED, ARCHIVED)
142
+ tags_filter: Filter by tags
143
+ output_json: Output as JSON
144
+ detailed: Show detailed information
145
+ verbose: Show verbose output
146
+
147
+ Returns:
148
+ Exit code (0 for success, 1 for error)
149
+ """
150
+ try:
151
+ # Get experiments via GraphQL
152
+ experiments = remote_client.list_experiments_graphql(
153
+ project, status=status_filter
154
+ )
155
+
156
+ # Filter by tags if specified
157
+ if tags_filter:
158
+ experiments = [
159
+ exp for exp in experiments
160
+ if any(tag in exp.get('tags', []) for tag in tags_filter)
161
+ ]
162
+
163
+ if output_json:
164
+ # JSON output
165
+ output = {
166
+ "project": project,
167
+ "experiments": experiments,
168
+ "count": len(experiments)
169
+ }
170
+ console.print(json.dumps(output, indent=2))
171
+ return 0
172
+
173
+ # Human-readable output
174
+ if not experiments:
175
+ console.print(f"[yellow]No experiments found in project: {project}[/yellow]")
176
+ return 0
177
+
178
+ console.print(f"\n[bold]Experiments in project: {project}[/bold]\n")
179
+
180
+ # Create table
181
+ table = Table(box=box.ROUNDED)
182
+ table.add_column("Experiment", style="cyan", no_wrap=True)
183
+ table.add_column("Status", justify="center")
184
+ table.add_column("Metrics", justify="right")
185
+ table.add_column("Logs", justify="right")
186
+ table.add_column("Files", justify="right")
187
+
188
+ if detailed:
189
+ table.add_column("Tags", style="dim")
190
+ table.add_column("Created", style="dim")
191
+
192
+ for exp in experiments:
193
+ status = exp.get('status', 'UNKNOWN')
194
+ status_style = _get_status_style(status)
195
+
196
+ # Count metrics
197
+ metrics_count = len(exp.get('metrics', []))
198
+
199
+ # Count logs
200
+ log_metadata = exp.get('logMetadata') or {}
201
+ logs_count = log_metadata.get('totalLogs', 0)
202
+
203
+ # Count files
204
+ files_count = len(exp.get('files', []))
205
+
206
+ row = [
207
+ exp['name'],
208
+ f"[{status_style}]{status}[/{status_style}]",
209
+ str(metrics_count),
210
+ str(logs_count),
211
+ str(files_count),
212
+ ]
213
+
214
+ if detailed:
215
+ # Add tags
216
+ tags = exp.get('tags', [])
217
+ tags_str = ', '.join(tags[:3])
218
+ if len(tags) > 3:
219
+ tags_str += f" +{len(tags) - 3}"
220
+ row.append(tags_str or '-')
221
+
222
+ # Add created time
223
+ created_at = exp.get('createdAt', '')
224
+ row.append(_format_timestamp(created_at) if created_at else '-')
225
+
226
+ table.add_row(*row)
227
+
228
+ console.print(table)
229
+ console.print(f"\n[dim]Total: {len(experiments)} experiment(s)[/dim]\n")
230
+
231
+ return 0
232
+
233
+ except Exception as e:
234
+ console.print(f"[red]Error listing experiments:[/red] {e}")
235
+ if verbose:
236
+ import traceback
237
+ console.print(traceback.format_exc())
238
+ return 1
239
+
240
+
241
+ def cmd_list(args: argparse.Namespace) -> int:
242
+ """
243
+ Execute list command.
244
+
245
+ Args:
246
+ args: Parsed command-line arguments
247
+
248
+ Returns:
249
+ Exit code (0 for success, 1 for error)
250
+ """
251
+ # Load config
252
+ config = Config()
253
+
254
+ # Get remote URL (command line > config)
255
+ remote_url = args.remote or config.remote_url
256
+ if not remote_url:
257
+ console.print("[red]Error:[/red] --remote URL is required (or set in config)")
258
+ return 1
259
+
260
+ # Get API key (command line > config > auto-loaded from storage)
261
+ api_key = args.api_key or config.api_key
262
+
263
+ # Create remote client
264
+ try:
265
+ remote_client = RemoteClient(base_url=remote_url, api_key=api_key)
266
+ except Exception as e:
267
+ console.print(f"[red]Error connecting to remote:[/red] {e}")
268
+ return 1
269
+
270
+ # List projects or experiments
271
+ if args.project:
272
+ # Parse tags if provided
273
+ tags_filter = None
274
+ if args.tags:
275
+ tags_filter = [tag.strip() for tag in args.tags.split(',')]
276
+
277
+ return list_experiments(
278
+ remote_client=remote_client,
279
+ project=args.project,
280
+ status_filter=args.status,
281
+ tags_filter=tags_filter,
282
+ output_json=args.json,
283
+ detailed=args.detailed,
284
+ verbose=args.verbose
285
+ )
286
+ else:
287
+ return list_projects(
288
+ remote_client=remote_client,
289
+ output_json=args.json,
290
+ verbose=args.verbose
291
+ )
292
+
293
+
294
+ def add_parser(subparsers) -> None:
295
+ """Add list command parser to subparsers."""
296
+ parser = subparsers.add_parser(
297
+ "list",
298
+ help="List projects and experiments on remote server",
299
+ description="Discover projects and experiments available on the remote ML-Dash server."
300
+ )
301
+
302
+ # Remote configuration
303
+ parser.add_argument("--remote", type=str, help="Remote server URL")
304
+ parser.add_argument(
305
+ "--api-key",
306
+ type=str,
307
+ help="JWT authentication token (auto-loaded from storage if not provided)"
308
+ )
309
+ # Filtering options
310
+ parser.add_argument("--project", type=str, help="List experiments in this project")
311
+ parser.add_argument("--status", type=str,
312
+ choices=["COMPLETED", "RUNNING", "FAILED", "ARCHIVED"],
313
+ help="Filter experiments by status")
314
+ parser.add_argument("--tags", type=str, help="Filter experiments by tags (comma-separated)")
315
+
316
+ # Output options
317
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
318
+ parser.add_argument("--detailed", action="store_true", help="Show detailed information")
319
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
@@ -0,0 +1,225 @@
1
+ """Login command for ml-dash CLI."""
2
+
3
+ import sys
4
+ import webbrowser
5
+ from typing import Optional
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+
11
+ from ml_dash.auth.device_flow import DeviceFlowClient
12
+ from ml_dash.auth.device_secret import get_or_create_device_secret
13
+ from ml_dash.auth.token_storage import get_token_storage
14
+ from ml_dash.auth.exceptions import (
15
+ DeviceCodeExpiredError,
16
+ AuthorizationDeniedError,
17
+ TokenExchangeError,
18
+ )
19
+ from ml_dash.config import config
20
+
21
+
22
+ def add_parser(subparsers):
23
+ """Add login command parser.
24
+
25
+ Args:
26
+ subparsers: Subparsers object from argparse
27
+ """
28
+ parser = subparsers.add_parser(
29
+ "login",
30
+ help="Authenticate with ml-dash using device authorization flow",
31
+ description="Login to ml-dash server using OAuth2 device authorization flow",
32
+ )
33
+
34
+ parser.add_argument(
35
+ "--remote",
36
+ type=str,
37
+ help="ML-Dash server URL (e.g., https://api.ml-dash.com)",
38
+ )
39
+
40
+ parser.add_argument(
41
+ "--no-browser",
42
+ action="store_true",
43
+ help="Don't automatically open browser for authorization",
44
+ )
45
+
46
+
47
+ def generate_qr_code_ascii(url: str) -> str:
48
+ """Generate ASCII QR code for the given URL.
49
+
50
+ Args:
51
+ url: URL to encode in QR code
52
+
53
+ Returns:
54
+ ASCII art QR code string
55
+ """
56
+ try:
57
+ import qrcode
58
+
59
+ qr = qrcode.QRCode(border=1)
60
+ qr.add_data(url)
61
+ qr.make(fit=True)
62
+
63
+ # Generate ASCII art
64
+ output = []
65
+ for row in qr.get_matrix():
66
+ line = ""
67
+ for cell in row:
68
+ line += "██" if cell else " "
69
+ output.append(line)
70
+
71
+ return "\n".join(output)
72
+ except ImportError:
73
+ return "[QR code unavailable - install qrcode: pip install qrcode]"
74
+ except Exception:
75
+ return "[QR code generation failed]"
76
+
77
+
78
+ def cmd_login(args) -> int:
79
+ """Execute login command.
80
+
81
+ Args:
82
+ args: Parsed command-line arguments
83
+
84
+ Returns:
85
+ Exit code (0 for success, 1 for failure)
86
+ """
87
+ console = Console()
88
+
89
+ # Get remote URL
90
+ remote_url = args.remote or config.remote_url
91
+ if not remote_url:
92
+ console.print(
93
+ "[red]Error: No remote URL configured.[/red]\n\n"
94
+ "Please specify with --remote or set default:\n"
95
+ " ml-dash login --remote https://api.ml-dash.com"
96
+ )
97
+ return 1
98
+
99
+ try:
100
+ # Initialize device flow
101
+ console.print("[bold]Initializing device authorization...[/bold]\n")
102
+
103
+ device_secret = get_or_create_device_secret(config)
104
+ device_client = DeviceFlowClient(
105
+ device_secret=device_secret, ml_dash_server_url=remote_url
106
+ )
107
+
108
+ # Start device flow with vuer-auth
109
+ flow = device_client.start_device_flow()
110
+
111
+ # Generate QR code
112
+ qr_code = generate_qr_code_ascii(flow.verification_uri_complete)
113
+
114
+ # Display rich UI with QR code
115
+ panel_content = (
116
+ f"[bold cyan]1. Visit this URL:[/bold cyan]\n\n"
117
+ f" {flow.verification_uri}\n\n"
118
+ f"[bold cyan]2. Enter this code:[/bold cyan]\n\n"
119
+ f" [bold green]{flow.user_code}[/bold green]\n\n"
120
+ )
121
+
122
+ # Add QR code if available
123
+ if "unavailable" not in qr_code and "failed" not in qr_code:
124
+ panel_content += f"[bold cyan]Or scan QR code:[/bold cyan]\n\n{qr_code}\n\n"
125
+
126
+ panel_content += f"[dim]Code expires in {flow.expires_in // 60} minutes[/dim]"
127
+
128
+ panel = Panel(
129
+ panel_content,
130
+ title="[bold blue]DEVICE AUTHORIZATION REQUIRED[/bold blue]",
131
+ border_style="blue",
132
+ expand=False,
133
+ )
134
+ console.print(panel)
135
+ console.print()
136
+
137
+ # Auto-open browser unless disabled
138
+ if not args.no_browser:
139
+ try:
140
+ webbrowser.open(flow.verification_uri_complete)
141
+ console.print("[dim]✓ Opened browser automatically[/dim]\n")
142
+ except Exception:
143
+ # Silent failure - user can manually open URL
144
+ pass
145
+
146
+ # Poll for authorization with progress indicator
147
+ console.print("[bold]Waiting for authorization...[/bold]")
148
+
149
+ with Progress(
150
+ SpinnerColumn(),
151
+ TextColumn("[progress.description]{task.description}"),
152
+ console=console,
153
+ transient=True,
154
+ ) as progress:
155
+ task = progress.add_task("Polling", total=None)
156
+
157
+ def update_progress(elapsed: int):
158
+ progress.update(task, description=f"Waiting ({elapsed}s)")
159
+
160
+ try:
161
+ vuer_auth_token = device_client.poll_for_token(
162
+ max_attempts=120, progress_callback=update_progress
163
+ )
164
+ except DeviceCodeExpiredError:
165
+ console.print(
166
+ "\n[red]✗ Device code expired[/red]\n\n"
167
+ "The authorization code expired after 10 minutes.\n"
168
+ "Please run 'ml-dash login' again."
169
+ )
170
+ return 1
171
+ except AuthorizationDeniedError:
172
+ console.print(
173
+ "\n[red]✗ Authorization denied[/red]\n\n"
174
+ "You declined the authorization request in your browser.\n\n"
175
+ "To try again:\n"
176
+ " ml-dash login"
177
+ )
178
+ return 1
179
+ except TimeoutError:
180
+ console.print(
181
+ "\n[red]✗ Authorization timed out[/red]\n\n"
182
+ "No response after 10 minutes.\n\n"
183
+ "Please run 'ml-dash login' again."
184
+ )
185
+ return 1
186
+
187
+ console.print("[green]✓ Authorization successful![/green]\n")
188
+
189
+ # Exchange vuer-auth token for ml-dash token
190
+ console.print("[bold]Exchanging token with ml-dash server...[/bold]")
191
+
192
+ try:
193
+ ml_dash_token = device_client.exchange_token(vuer_auth_token)
194
+ except TokenExchangeError as e:
195
+ console.print(f"\n[red]✗ Token exchange failed:[/red] {e}\n")
196
+ return 1
197
+
198
+ # Store ml-dash permanent token
199
+ storage = get_token_storage()
200
+ storage.store("ml-dash-token", ml_dash_token)
201
+
202
+ console.print("[green]✓ Token exchanged successfully![/green]\n")
203
+
204
+ # Success message
205
+ console.print(
206
+ "[bold green]✓ Logged in successfully![/bold green]\n\n"
207
+ "Your authentication token has been securely stored.\n"
208
+ "You can now use ml-dash commands without --api-key.\n\n"
209
+ "Examples:\n"
210
+ " ml-dash upload ./experiments\n"
211
+ " ml-dash download ./output\n"
212
+ " ml-dash list"
213
+ )
214
+
215
+ return 0
216
+
217
+ except KeyboardInterrupt:
218
+ console.print("\n\n[yellow]Login cancelled by user.[/yellow]")
219
+ return 1
220
+ except Exception as e:
221
+ console.print(f"\n[red]✗ Unexpected error:[/red] {e}")
222
+ import traceback
223
+
224
+ console.print(f"\n[dim]{traceback.format_exc()}[/dim]")
225
+ return 1
@@ -0,0 +1,54 @@
1
+ """Logout command for ml-dash CLI."""
2
+
3
+ from rich.console import Console
4
+
5
+ from ml_dash.auth.token_storage import get_token_storage
6
+ from ml_dash.auth.exceptions import StorageError
7
+
8
+
9
+ def add_parser(subparsers):
10
+ """Add logout command parser.
11
+
12
+ Args:
13
+ subparsers: Subparsers object from argparse
14
+ """
15
+ parser = subparsers.add_parser(
16
+ "logout",
17
+ help="Clear stored authentication token",
18
+ description="Logout from ml-dash by clearing stored authentication token",
19
+ )
20
+
21
+
22
+ def cmd_logout(args) -> int:
23
+ """Execute logout command.
24
+
25
+ Args:
26
+ args: Parsed command-line arguments
27
+
28
+ Returns:
29
+ Exit code (0 for success, 1 for failure)
30
+ """
31
+ console = Console()
32
+
33
+ try:
34
+ # Get storage backend
35
+ storage = get_token_storage()
36
+
37
+ # Delete stored token
38
+ storage.delete("ml-dash-token")
39
+
40
+ console.print(
41
+ "[bold green]✓ Logged out successfully![/bold green]\n\n"
42
+ "Your authentication token has been cleared.\n\n"
43
+ "To log in again:\n"
44
+ " ml-dash login"
45
+ )
46
+
47
+ return 0
48
+
49
+ except StorageError as e:
50
+ console.print(f"[red]✗ Storage error:[/red] {e}")
51
+ return 1
52
+ except Exception as e:
53
+ console.print(f"[red]✗ Unexpected error:[/red] {e}")
54
+ return 1