ml-dash 0.6.2rc1__py3-none-any.whl → 0.6.3__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.
@@ -252,9 +252,9 @@ def cmd_list(args: argparse.Namespace) -> int:
252
252
  config = Config()
253
253
 
254
254
  # Get remote URL (command line > config)
255
- remote_url = args.remote or config.remote_url
255
+ remote_url = args.dash_url or config.remote_url
256
256
  if not remote_url:
257
- console.print("[red]Error:[/red] --remote URL is required (or set in config)")
257
+ console.print("[red]Error:[/red] --dash-url is required (or set in config)")
258
258
  return 1
259
259
 
260
260
  # Get API key (command line > config > auto-loaded from storage)
@@ -274,15 +274,135 @@ def cmd_list(args: argparse.Namespace) -> int:
274
274
  if args.tags:
275
275
  tags_filter = [tag.strip() for tag in args.tags.split(',')]
276
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
- )
277
+ # Check if pattern contains wildcards
278
+ has_wildcards = any(c in args.project for c in ['*', '?', '['])
279
+
280
+ if has_wildcards:
281
+ # Use searchExperiments GraphQL query for glob patterns
282
+ try:
283
+ # Expand simple project patterns to full namespace/project/experiment format
284
+ # If pattern has no slashes, assume it's just a project pattern
285
+ if '/' not in args.project:
286
+ # Simple project pattern: "tut*" -> "*/tut*/*"
287
+ search_pattern = f"*/{args.project}/*"
288
+ else:
289
+ # Full or partial pattern: use as-is
290
+ search_pattern = args.project
291
+
292
+ experiments = remote_client.search_experiments_graphql(search_pattern)
293
+
294
+ # Apply status filter if specified (server doesn't support it in searchExperiments yet)
295
+ if args.status:
296
+ experiments = [
297
+ exp for exp in experiments
298
+ if exp.get('status') == args.status
299
+ ]
300
+
301
+ # Apply tags filter if specified
302
+ if tags_filter:
303
+ experiments = [
304
+ exp for exp in experiments
305
+ if any(tag in exp.get('tags', []) for tag in tags_filter)
306
+ ]
307
+
308
+ if args.json:
309
+ # JSON output
310
+ output = {
311
+ "pattern": search_pattern,
312
+ "experiments": experiments,
313
+ "count": len(experiments)
314
+ }
315
+ console.print(json.dumps(output, indent=2))
316
+ return 0
317
+
318
+ # Human-readable output
319
+ if not experiments:
320
+ console.print(f"[yellow]No experiments match pattern: {search_pattern}[/yellow]")
321
+ return 0
322
+
323
+ # Group experiments by project for better display
324
+ from collections import defaultdict
325
+ projects_map = defaultdict(list)
326
+ for exp in experiments:
327
+ project_slug = exp.get('project', {}).get('slug', 'unknown')
328
+ projects_map[project_slug].append(exp)
329
+
330
+ # Display each project's experiments
331
+ for project_slug in sorted(projects_map.keys()):
332
+ project_experiments = projects_map[project_slug]
333
+ console.print(f"\n[bold]Experiments in project: {project_slug}[/bold]\n")
334
+
335
+ # Create table
336
+ table = Table(box=box.ROUNDED)
337
+ table.add_column("Experiment", style="cyan", no_wrap=True)
338
+ table.add_column("Status", justify="center")
339
+ table.add_column("Metrics", justify="right")
340
+ table.add_column("Logs", justify="right")
341
+ table.add_column("Files", justify="right")
342
+
343
+ if args.detailed:
344
+ table.add_column("Tags", style="dim")
345
+ table.add_column("Started", style="dim")
346
+
347
+ for exp in project_experiments:
348
+ status = exp.get('status', 'UNKNOWN')
349
+ status_style = _get_status_style(status)
350
+
351
+ # Count metrics
352
+ metrics_count = len(exp.get('metrics', []))
353
+
354
+ # Count logs
355
+ log_metadata = exp.get('logMetadata') or {}
356
+ logs_count = log_metadata.get('totalLogs', 0)
357
+
358
+ # Count files
359
+ files_count = len(exp.get('files', []))
360
+
361
+ row = [
362
+ exp['name'],
363
+ f"[{status_style}]{status}[/{status_style}]",
364
+ str(metrics_count),
365
+ str(logs_count),
366
+ str(files_count),
367
+ ]
368
+
369
+ if args.detailed:
370
+ # Add tags
371
+ tags = exp.get('tags', [])
372
+ tags_str = ', '.join(tags[:3])
373
+ if len(tags) > 3:
374
+ tags_str += f" +{len(tags) - 3}"
375
+ row.append(tags_str or '-')
376
+
377
+ # Add started time (createdAt doesn't exist, use startedAt)
378
+ started_at = exp.get('startedAt', '')
379
+ row.append(_format_timestamp(started_at) if started_at else '-')
380
+
381
+ table.add_row(*row)
382
+
383
+ console.print(table)
384
+ console.print(f"[dim]Total: {len(project_experiments)} experiment(s)[/dim]")
385
+
386
+ console.print(f"\n[dim]Grand total: {len(experiments)} experiment(s) across {len(projects_map)} project(s)[/dim]\n")
387
+ return 0
388
+
389
+ except Exception as e:
390
+ console.print(f"[red]Error searching experiments:[/red] {e}")
391
+ if args.verbose:
392
+ import traceback
393
+ console.print(traceback.format_exc())
394
+ return 1
395
+ else:
396
+ # No wildcards, use existing list method for exact project match
397
+ return list_experiments(
398
+ remote_client=remote_client,
399
+ project=args.project,
400
+ status_filter=args.status,
401
+ tags_filter=tags_filter,
402
+ output_json=args.json,
403
+ detailed=args.detailed,
404
+ verbose=args.verbose
405
+ )
286
406
  else:
287
407
  return list_projects(
288
408
  remote_client=remote_client,
@@ -300,14 +420,27 @@ def add_parser(subparsers) -> None:
300
420
  )
301
421
 
302
422
  # Remote configuration
303
- parser.add_argument("--remote", type=str, help="Remote server URL")
423
+ parser.add_argument(
424
+ "--dash-url",
425
+ type=str,
426
+ help="ML-Dash server URL (defaults to config or https://api.dash.ml)",
427
+ )
304
428
  parser.add_argument(
305
429
  "--api-key",
306
430
  type=str,
307
431
  help="JWT authentication token (auto-loaded from storage if not provided)"
308
432
  )
309
433
  # Filtering options
310
- parser.add_argument("--project", type=str, help="List experiments in this project")
434
+ parser.add_argument(
435
+ "-p",
436
+ "--pref",
437
+ "--prefix",
438
+ "--proj",
439
+ "--project",
440
+ dest="project",
441
+ type=str,
442
+ help="List experiments in this project (supports glob: 'tutorial*', 'test-?', 'proj-[0-9]*')"
443
+ )
311
444
  parser.add_argument("--status", type=str,
312
445
  choices=["COMPLETED", "RUNNING", "FAILED", "ARCHIVED"],
313
446
  help="Filter experiments by status")
@@ -1,8 +1,6 @@
1
1
  """Login command for ml-dash CLI."""
2
2
 
3
- import sys
4
3
  import webbrowser
5
- from typing import Optional
6
4
 
7
5
  from rich.console import Console
8
6
  from rich.panel import Panel
@@ -10,216 +8,225 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
10
8
 
11
9
  from ml_dash.auth.device_flow import DeviceFlowClient
12
10
  from ml_dash.auth.device_secret import get_or_create_device_secret
13
- from ml_dash.auth.token_storage import get_token_storage
14
11
  from ml_dash.auth.exceptions import (
15
- DeviceCodeExpiredError,
16
- AuthorizationDeniedError,
17
- TokenExchangeError,
12
+ AuthorizationDeniedError,
13
+ DeviceCodeExpiredError,
14
+ TokenExchangeError,
18
15
  )
16
+ from ml_dash.auth.token_storage import get_token_storage
19
17
  from ml_dash.config import config
20
18
 
21
19
 
22
20
  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
- )
21
+ """Add login command parser.
22
+
23
+ Args:
24
+ subparsers: Subparsers object from argparse
25
+ """
26
+ parser = subparsers.add_parser(
27
+ "login",
28
+ help="Authenticate with ml-dash using device authorization flow",
29
+ description=(
30
+ "Login to ml-dash server using OAuth2 device authorization flow.\n\n"
31
+ "After logging in, you can:\n"
32
+ " • Upload/download experiments via CLI\n"
33
+ " • View projects, experiments, and statistics at https://dash.ml\n"
34
+ " • Create interactive plots and dashboards\n"
35
+ ),
36
+ )
37
+
38
+ parser.add_argument(
39
+ "--dash-url",
40
+ type=str,
41
+ help="ML-Dash server URL (e.g., https://api.dash.ml)",
42
+ )
43
+
44
+ parser.add_argument(
45
+ "--no-browser",
46
+ action="store_true",
47
+ help="Don't automatically open browser for authorization",
48
+ )
45
49
 
46
50
 
47
51
  def generate_qr_code_ascii(url: str) -> str:
48
- """Generate ASCII QR code for the given URL.
52
+ """Generate ASCII QR code for the given URL.
49
53
 
50
- Args:
51
- url: URL to encode in QR code
54
+ Args:
55
+ url: URL to encode in QR code
52
56
 
53
- Returns:
54
- ASCII art QR code string
55
- """
56
- try:
57
- import qrcode
57
+ Returns:
58
+ ASCII art QR code string
59
+ """
60
+ try:
61
+ import qrcode
58
62
 
59
- qr = qrcode.QRCode(border=1)
60
- qr.add_data(url)
61
- qr.make(fit=True)
63
+ qr = qrcode.QRCode(border=1)
64
+ qr.add_data(url)
65
+ qr.make(fit=True)
62
66
 
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)
67
+ # Generate ASCII art
68
+ output = []
69
+ for row in qr.get_matrix():
70
+ line = ""
71
+ for cell in row:
72
+ line += "██" if cell else " "
73
+ output.append(line)
70
74
 
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]"
75
+ return "\n".join(output)
76
+ except ImportError:
77
+ return "[QR code unavailable - install qrcode: pip install qrcode]"
78
+ except Exception:
79
+ return "[QR code generation failed]"
76
80
 
77
81
 
78
82
  def cmd_login(args) -> int:
79
- """Execute login command.
83
+ """Execute login command.
84
+
85
+ Args:
86
+ args: Parsed command-line arguments
87
+
88
+ Returns:
89
+ Exit code (0 for success, 1 for failure)
90
+ """
91
+ console = Console()
92
+
93
+ # Get remote URL
94
+ remote_url = args.dash_url or config.remote_url
95
+ if not remote_url:
96
+ console.print(
97
+ "[red]Error: No remote URL configured.[/red]\n\n"
98
+ "Please specify with --dash-url or set default:\n"
99
+ " ml-dash login --dash-url https://api.dash.ml"
100
+ )
101
+ return 1
80
102
 
81
- Args:
82
- args: Parsed command-line arguments
103
+ try:
104
+ # Initialize device flow
105
+ console.print("[bold]Initializing device authorization...[/bold]\n")
83
106
 
84
- Returns:
85
- Exit code (0 for success, 1 for failure)
86
- """
87
- console = Console()
107
+ device_secret = get_or_create_device_secret(config)
108
+ device_client = DeviceFlowClient(
109
+ device_secret=device_secret, ml_dash_server_url=remote_url
110
+ )
88
111
 
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
112
+ # Start device flow with vuer-auth
113
+ flow = device_client.start_device_flow()
98
114
 
99
- try:
100
- # Initialize device flow
101
- console.print("[bold]Initializing device authorization...[/bold]\n")
115
+ # Generate QR code
116
+ qr_code = generate_qr_code_ascii(flow.verification_uri_complete)
102
117
 
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
- )
118
+ # Display rich UI with QR code
119
+ panel_content = (
120
+ f"[bold cyan]1. Visit this URL:[/bold cyan]\n\n"
121
+ f" {flow.verification_uri}\n\n"
122
+ f"[bold cyan]2. Enter this code:[/bold cyan]\n\n"
123
+ f" [bold green]{flow.user_code}[/bold green]\n\n"
124
+ )
107
125
 
108
- # Start device flow with vuer-auth
109
- flow = device_client.start_device_flow()
126
+ # Add QR code if available
127
+ if "unavailable" not in qr_code and "failed" not in qr_code:
128
+ panel_content += f"[bold cyan]Or scan QR code:[/bold cyan]\n\n{qr_code}\n\n"
110
129
 
111
- # Generate QR code
112
- qr_code = generate_qr_code_ascii(flow.verification_uri_complete)
130
+ panel_content += f"[dim]Code expires in {flow.expires_in // 60} minutes[/dim]"
113
131
 
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"
132
+ panel = Panel(
133
+ panel_content,
134
+ title="[bold blue]DEVICE AUTHORIZATION REQUIRED[/bold blue]",
135
+ border_style="blue",
136
+ expand=False,
137
+ )
138
+ console.print(panel)
139
+ console.print()
140
+
141
+ # Auto-open browser unless disabled
142
+ if not args.no_browser:
143
+ try:
144
+ webbrowser.open(flow.verification_uri_complete)
145
+ console.print("[dim]✓ Opened browser automatically[/dim]\n")
146
+ except Exception:
147
+ # Silent failure - user can manually open URL
148
+ pass
149
+
150
+ # Poll for authorization with progress indicator
151
+ console.print("[bold]Waiting for authorization...[/bold]")
152
+
153
+ with Progress(
154
+ SpinnerColumn(),
155
+ TextColumn("[progress.description]{task.description}"),
156
+ console=console,
157
+ transient=True,
158
+ ) as progress:
159
+ task = progress.add_task("Polling", total=None)
160
+
161
+ def update_progress(elapsed: int):
162
+ progress.update(task, description=f"Waiting ({elapsed}s)")
163
+
164
+ try:
165
+ vuer_auth_token = device_client.poll_for_token(
166
+ max_attempts=120, progress_callback=update_progress
120
167
  )
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,
168
+ except DeviceCodeExpiredError:
169
+ console.print(
170
+ "\n[red]✗ Device code expired[/red]\n\n"
171
+ "The authorization code expired after 10 minutes.\n"
172
+ "Please run 'ml-dash login' again."
133
173
  )
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
174
+ return 1
175
+ except AuthorizationDeniedError:
176
+ console.print(
177
+ "\n[red]✗ Authorization denied[/red]\n\n"
178
+ "You declined the authorization request in your browser.\n\n"
179
+ "To try again:\n"
180
+ " ml-dash login"
181
+ )
182
+ return 1
183
+ except TimeoutError:
205
184
  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"
185
+ "\n[red] Authorization timed out[/red]\n\n"
186
+ "No response after 10 minutes.\n\n"
187
+ "Please run 'ml-dash login' again."
213
188
  )
189
+ return 1
214
190
 
215
- return 0
191
+ console.print("[green]✓ Authorization successful![/green]\n")
216
192
 
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
193
+ # Exchange vuer-auth token for ml-dash token
194
+ console.print("[bold]Exchanging token with ml-dash server...[/bold]")
223
195
 
224
- console.print(f"\n[dim]{traceback.format_exc()}[/dim]")
225
- return 1
196
+ try:
197
+ ml_dash_token = device_client.exchange_token(vuer_auth_token)
198
+ except TokenExchangeError as e:
199
+ console.print(f"\n[red]✗ Token exchange failed:[/red] {e}\n")
200
+ return 1
201
+
202
+ # Store ml-dash permanent token
203
+ storage = get_token_storage()
204
+ storage.store("ml-dash-token", ml_dash_token)
205
+
206
+ console.print("[green]✓ Token exchanged successfully![/green]\n")
207
+
208
+ # Success message
209
+ console.print(
210
+ "[bold green]✓ Logged in successfully![/bold green]\n\n"
211
+ "Your authentication token has been securely stored.\n"
212
+ "You can now use ml-dash commands without --api-key.\n\n"
213
+ "[bold cyan]View your data online:[/bold cyan]\n"
214
+ " [link=https://dash.ml]https://dash.ml[/link]\n\n"
215
+ "Access your projects, experiments, statistics, and interactive plots.\n\n"
216
+ "[bold]CLI Commands:[/bold]\n"
217
+ " ml-dash upload ./experiments\n"
218
+ " ml-dash download ./output\n"
219
+ " ml-dash list"
220
+ )
221
+
222
+ return 0
223
+
224
+ except KeyboardInterrupt:
225
+ console.print("\n\n[yellow]Login cancelled by user.[/yellow]")
226
+ return 1
227
+ except Exception as e:
228
+ console.print(f"\n[red]✗ Unexpected error:[/red] {e}")
229
+ import traceback
230
+
231
+ console.print(f"\n[dim]{traceback.format_exc()}[/dim]")
232
+ return 1