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.
- ml_dash/__init__.py +85 -0
- 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 +52 -0
- ml_dash/cli.py +79 -0
- ml_dash/cli_commands/__init__.py +1 -0
- ml_dash/cli_commands/download.py +769 -0
- ml_dash/cli_commands/list.py +319 -0
- ml_dash/cli_commands/login.py +225 -0
- ml_dash/cli_commands/logout.py +54 -0
- ml_dash/cli_commands/upload.py +1248 -0
- ml_dash/client.py +1003 -0
- ml_dash/config.py +133 -0
- ml_dash/experiment.py +1116 -0
- ml_dash/files.py +785 -0
- ml_dash/log.py +181 -0
- ml_dash/metric.py +481 -0
- ml_dash/params.py +277 -0
- ml_dash/py.typed +0 -0
- ml_dash/remote_auto_start.py +55 -0
- ml_dash/storage.py +1127 -0
- ml_dash-0.6.1.dist-info/METADATA +248 -0
- ml_dash-0.6.1.dist-info/RECORD +29 -0
- ml_dash-0.6.1.dist-info/WHEEL +4 -0
- ml_dash-0.6.1.dist-info/entry_points.txt +3 -0
|
@@ -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
|