ml-dash 0.5.7__py3-none-any.whl → 0.5.8__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/cli.py +67 -0
- ml_dash/cli_commands/__init__.py +1 -0
- ml_dash/cli_commands/download.py +797 -0
- ml_dash/cli_commands/list.py +343 -0
- ml_dash/cli_commands/upload.py +1298 -0
- ml_dash/client.py +360 -0
- ml_dash/config.py +119 -0
- ml_dash/storage.py +64 -13
- {ml_dash-0.5.7.dist-info → ml_dash-0.5.8.dist-info}/METADATA +2 -1
- ml_dash-0.5.8.dist-info/RECORD +20 -0
- ml_dash-0.5.8.dist-info/entry_points.txt +3 -0
- ml_dash-0.5.7.dist-info/RECORD +0 -13
- {ml_dash-0.5.7.dist-info → ml_dash-0.5.8.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
namespace: str,
|
|
62
|
+
output_json: bool = False,
|
|
63
|
+
verbose: bool = False
|
|
64
|
+
) -> int:
|
|
65
|
+
"""
|
|
66
|
+
List all projects for the user.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
remote_client: Remote API client
|
|
70
|
+
namespace: Namespace slug
|
|
71
|
+
output_json: Output as JSON
|
|
72
|
+
verbose: Show verbose output
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Exit code (0 for success, 1 for error)
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
# Get projects via GraphQL
|
|
79
|
+
projects = remote_client.list_projects_graphql(namespace)
|
|
80
|
+
|
|
81
|
+
if output_json:
|
|
82
|
+
# JSON output
|
|
83
|
+
output = {
|
|
84
|
+
"namespace": namespace,
|
|
85
|
+
"projects": projects,
|
|
86
|
+
"count": len(projects)
|
|
87
|
+
}
|
|
88
|
+
console.print(json.dumps(output, indent=2))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
# Human-readable output
|
|
92
|
+
if not projects:
|
|
93
|
+
console.print(f"[yellow]No projects found for namespace: {namespace}[/yellow]")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
console.print(f"\n[bold]Projects for {namespace}[/bold]\n")
|
|
97
|
+
|
|
98
|
+
# Create table
|
|
99
|
+
table = Table(box=box.ROUNDED)
|
|
100
|
+
table.add_column("Project", style="cyan", no_wrap=True)
|
|
101
|
+
table.add_column("Experiments", justify="right")
|
|
102
|
+
table.add_column("Description", style="dim")
|
|
103
|
+
|
|
104
|
+
for project in projects:
|
|
105
|
+
exp_count = project.get('experimentCount', 0)
|
|
106
|
+
description = project.get('description', '') or ''
|
|
107
|
+
if len(description) > 50:
|
|
108
|
+
description = description[:47] + "..."
|
|
109
|
+
|
|
110
|
+
table.add_row(
|
|
111
|
+
project['slug'],
|
|
112
|
+
str(exp_count),
|
|
113
|
+
description
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
console.print(table)
|
|
117
|
+
console.print(f"\n[dim]Total: {len(projects)} project(s)[/dim]\n")
|
|
118
|
+
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
console.print(f"[red]Error listing projects:[/red] {e}")
|
|
123
|
+
if verbose:
|
|
124
|
+
import traceback
|
|
125
|
+
console.print(traceback.format_exc())
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def list_experiments(
|
|
130
|
+
remote_client: RemoteClient,
|
|
131
|
+
namespace: str,
|
|
132
|
+
project: str,
|
|
133
|
+
status_filter: Optional[str] = None,
|
|
134
|
+
tags_filter: Optional[List[str]] = None,
|
|
135
|
+
output_json: bool = False,
|
|
136
|
+
detailed: bool = False,
|
|
137
|
+
verbose: bool = False
|
|
138
|
+
) -> int:
|
|
139
|
+
"""
|
|
140
|
+
List experiments in a project.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
remote_client: Remote API client
|
|
144
|
+
namespace: Namespace slug
|
|
145
|
+
project: Project slug
|
|
146
|
+
status_filter: Filter by status (COMPLETED, RUNNING, FAILED, ARCHIVED)
|
|
147
|
+
tags_filter: Filter by tags
|
|
148
|
+
output_json: Output as JSON
|
|
149
|
+
detailed: Show detailed information
|
|
150
|
+
verbose: Show verbose output
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Exit code (0 for success, 1 for error)
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
# Get experiments via GraphQL
|
|
157
|
+
experiments = remote_client.list_experiments_graphql(
|
|
158
|
+
namespace, project, status=status_filter
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Filter by tags if specified
|
|
162
|
+
if tags_filter:
|
|
163
|
+
experiments = [
|
|
164
|
+
exp for exp in experiments
|
|
165
|
+
if any(tag in exp.get('tags', []) for tag in tags_filter)
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
if output_json:
|
|
169
|
+
# JSON output
|
|
170
|
+
output = {
|
|
171
|
+
"namespace": namespace,
|
|
172
|
+
"project": project,
|
|
173
|
+
"experiments": experiments,
|
|
174
|
+
"count": len(experiments)
|
|
175
|
+
}
|
|
176
|
+
console.print(json.dumps(output, indent=2))
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
# Human-readable output
|
|
180
|
+
if not experiments:
|
|
181
|
+
console.print(f"[yellow]No experiments found in project: {project}[/yellow]")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
console.print(f"\n[bold]Experiments in project: {project}[/bold]\n")
|
|
185
|
+
|
|
186
|
+
# Create table
|
|
187
|
+
table = Table(box=box.ROUNDED)
|
|
188
|
+
table.add_column("Experiment", style="cyan", no_wrap=True)
|
|
189
|
+
table.add_column("Status", justify="center")
|
|
190
|
+
table.add_column("Metrics", justify="right")
|
|
191
|
+
table.add_column("Logs", justify="right")
|
|
192
|
+
table.add_column("Files", justify="right")
|
|
193
|
+
|
|
194
|
+
if detailed:
|
|
195
|
+
table.add_column("Tags", style="dim")
|
|
196
|
+
table.add_column("Created", style="dim")
|
|
197
|
+
|
|
198
|
+
for exp in experiments:
|
|
199
|
+
status = exp.get('status', 'UNKNOWN')
|
|
200
|
+
status_style = _get_status_style(status)
|
|
201
|
+
|
|
202
|
+
# Count metrics
|
|
203
|
+
metrics_count = len(exp.get('metrics', []))
|
|
204
|
+
|
|
205
|
+
# Count logs
|
|
206
|
+
log_metadata = exp.get('logMetadata') or {}
|
|
207
|
+
logs_count = log_metadata.get('totalLogs', 0)
|
|
208
|
+
|
|
209
|
+
# Count files
|
|
210
|
+
files_count = len(exp.get('files', []))
|
|
211
|
+
|
|
212
|
+
row = [
|
|
213
|
+
exp['name'],
|
|
214
|
+
f"[{status_style}]{status}[/{status_style}]",
|
|
215
|
+
str(metrics_count),
|
|
216
|
+
str(logs_count),
|
|
217
|
+
str(files_count),
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
if detailed:
|
|
221
|
+
# Add tags
|
|
222
|
+
tags = exp.get('tags', [])
|
|
223
|
+
tags_str = ', '.join(tags[:3])
|
|
224
|
+
if len(tags) > 3:
|
|
225
|
+
tags_str += f" +{len(tags) - 3}"
|
|
226
|
+
row.append(tags_str or '-')
|
|
227
|
+
|
|
228
|
+
# Add created time
|
|
229
|
+
created_at = exp.get('createdAt', '')
|
|
230
|
+
row.append(_format_timestamp(created_at) if created_at else '-')
|
|
231
|
+
|
|
232
|
+
table.add_row(*row)
|
|
233
|
+
|
|
234
|
+
console.print(table)
|
|
235
|
+
console.print(f"\n[dim]Total: {len(experiments)} experiment(s)[/dim]\n")
|
|
236
|
+
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
except Exception as e:
|
|
240
|
+
console.print(f"[red]Error listing experiments:[/red] {e}")
|
|
241
|
+
if verbose:
|
|
242
|
+
import traceback
|
|
243
|
+
console.print(traceback.format_exc())
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
248
|
+
"""
|
|
249
|
+
Execute list command.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
args: Parsed command-line arguments
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Exit code (0 for success, 1 for error)
|
|
256
|
+
"""
|
|
257
|
+
# Load config
|
|
258
|
+
config = Config()
|
|
259
|
+
|
|
260
|
+
# Get remote URL (command line > config)
|
|
261
|
+
remote_url = args.remote or config.remote_url
|
|
262
|
+
if not remote_url:
|
|
263
|
+
console.print("[red]Error:[/red] --remote URL is required (or set in config)")
|
|
264
|
+
return 1
|
|
265
|
+
|
|
266
|
+
# Get API key (command line > config > generate from username)
|
|
267
|
+
api_key = args.api_key or config.api_key
|
|
268
|
+
|
|
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
|
+
# Create remote client
|
|
287
|
+
try:
|
|
288
|
+
remote_client = RemoteClient(base_url=remote_url, api_key=api_key)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
console.print(f"[red]Error connecting to remote:[/red] {e}")
|
|
291
|
+
return 1
|
|
292
|
+
|
|
293
|
+
# List projects or experiments
|
|
294
|
+
if args.project:
|
|
295
|
+
# Parse tags if provided
|
|
296
|
+
tags_filter = None
|
|
297
|
+
if args.tags:
|
|
298
|
+
tags_filter = [tag.strip() for tag in args.tags.split(',')]
|
|
299
|
+
|
|
300
|
+
return list_experiments(
|
|
301
|
+
remote_client=remote_client,
|
|
302
|
+
namespace=namespace,
|
|
303
|
+
project=args.project,
|
|
304
|
+
status_filter=args.status,
|
|
305
|
+
tags_filter=tags_filter,
|
|
306
|
+
output_json=args.json,
|
|
307
|
+
detailed=args.detailed,
|
|
308
|
+
verbose=args.verbose
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
return list_projects(
|
|
312
|
+
remote_client=remote_client,
|
|
313
|
+
namespace=namespace,
|
|
314
|
+
output_json=args.json,
|
|
315
|
+
verbose=args.verbose
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def add_parser(subparsers) -> None:
|
|
320
|
+
"""Add list command parser to subparsers."""
|
|
321
|
+
parser = subparsers.add_parser(
|
|
322
|
+
"list",
|
|
323
|
+
help="List projects and experiments on remote server",
|
|
324
|
+
description="Discover projects and experiments available on the remote ML-Dash server."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Remote configuration
|
|
328
|
+
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
|
+
|
|
333
|
+
# Filtering options
|
|
334
|
+
parser.add_argument("--project", type=str, help="List experiments in this project")
|
|
335
|
+
parser.add_argument("--status", type=str,
|
|
336
|
+
choices=["COMPLETED", "RUNNING", "FAILED", "ARCHIVED"],
|
|
337
|
+
help="Filter experiments by status")
|
|
338
|
+
parser.add_argument("--tags", type=str, help="Filter experiments by tags (comma-separated)")
|
|
339
|
+
|
|
340
|
+
# Output options
|
|
341
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
342
|
+
parser.add_argument("--detailed", action="store_true", help="Show detailed information")
|
|
343
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|