ml-dash 0.6.5__py3-none-any.whl → 0.6.7__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 CHANGED
@@ -25,7 +25,7 @@ def create_parser() -> argparse.ArgumentParser:
25
25
  )
26
26
 
27
27
  # Import and add command parsers
28
- from .cli_commands import upload, download, list as list_cmd, login, logout, profile, api
28
+ from .cli_commands import upload, download, list as list_cmd, login, logout, profile, api, create
29
29
 
30
30
  # Authentication commands
31
31
  login.add_parser(subparsers)
@@ -35,6 +35,9 @@ def create_parser() -> argparse.ArgumentParser:
35
35
  # API commands
36
36
  api.add_parser(subparsers)
37
37
 
38
+ # Project commands
39
+ create.add_parser(subparsers)
40
+
38
41
  # Data commands
39
42
  upload.add_parser(subparsers)
40
43
  download.add_parser(subparsers)
@@ -71,6 +74,9 @@ def main(argv: Optional[List[str]] = None) -> int:
71
74
  elif args.command == "profile":
72
75
  from .cli_commands import profile
73
76
  return profile.cmd_profile(args)
77
+ elif args.command == "create":
78
+ from .cli_commands import create
79
+ return create.cmd_create(args)
74
80
  elif args.command == "upload":
75
81
  from .cli_commands import upload
76
82
  return upload.cmd_upload(args)
@@ -0,0 +1,145 @@
1
+ """Create command for ml-dash CLI - create projects."""
2
+
3
+ import argparse
4
+ from typing import Optional
5
+
6
+ from rich.console import Console
7
+
8
+ from ml_dash.client import RemoteClient
9
+ from ml_dash.config import config
10
+
11
+
12
+ def add_parser(subparsers):
13
+ """Add create command parser."""
14
+ parser = subparsers.add_parser(
15
+ "create",
16
+ help="Create a new project",
17
+ description="""Create a new project in ml-dash.
18
+
19
+ Examples:
20
+ # Create a project in current user's namespace
21
+ ml-dash create -p new-project
22
+
23
+ # Create a project in a specific namespace
24
+ ml-dash create -p geyang/new-project
25
+
26
+ # Create with description
27
+ ml-dash create -p geyang/tutorials -d "ML tutorials and examples"
28
+ """,
29
+ formatter_class=argparse.RawDescriptionHelpFormatter,
30
+ )
31
+ parser.add_argument(
32
+ "-p", "--prefix",
33
+ type=str,
34
+ required=True,
35
+ help="Project name or namespace/project",
36
+ )
37
+ parser.add_argument(
38
+ "-d", "--description",
39
+ type=str,
40
+ help="Project description (optional)",
41
+ )
42
+ parser.add_argument(
43
+ "--dash-url",
44
+ type=str,
45
+ help="ML-Dash server URL (default: https://api.dash.ml)",
46
+ )
47
+
48
+
49
+ def cmd_create(args) -> int:
50
+ """Execute create command."""
51
+ console = Console()
52
+
53
+ # Get remote URL
54
+ remote_url = args.dash_url or config.remote_url or "https://api.dash.ml"
55
+
56
+ # Parse the prefix
57
+ prefix = args.prefix.strip("/")
58
+ parts = prefix.split("/")
59
+
60
+ if len(parts) > 2:
61
+ console.print(
62
+ f"[red]Error:[/red] Prefix can have at most 2 parts (namespace/project).\n"
63
+ f"Got: {args.prefix}\n\n"
64
+ f"Examples:\n"
65
+ f" ml-dash create -p new-project\n"
66
+ f" ml-dash create -p geyang/new-project"
67
+ )
68
+ return 1
69
+
70
+ if len(parts) == 1:
71
+ # Format: project (use current user's namespace)
72
+ namespace = None
73
+ project_name = parts[0]
74
+ else:
75
+ # Format: namespace/project
76
+ namespace = parts[0]
77
+ project_name = parts[1]
78
+
79
+ return _create_project(
80
+ namespace=namespace,
81
+ project_name=project_name,
82
+ description=args.description,
83
+ dash_url=remote_url,
84
+ console=console,
85
+ )
86
+
87
+
88
+ def _create_project(
89
+ namespace: Optional[str],
90
+ project_name: str,
91
+ description: Optional[str],
92
+ dash_url: str,
93
+ console: Console,
94
+ ) -> int:
95
+ """Create a new project."""
96
+ try:
97
+ # Initialize client (namespace will be auto-fetched from server if not provided)
98
+ client = RemoteClient(base_url=dash_url, namespace=namespace)
99
+
100
+ # Get namespace (triggers server query if not set)
101
+ namespace = client.namespace
102
+
103
+ if not namespace:
104
+ console.print("[red]Error:[/red] Could not determine namespace. Please login first.")
105
+ return 1
106
+
107
+ console.print(f"[dim]Creating project '{project_name}' in namespace '{namespace}'[/dim]")
108
+
109
+ # Create project using unified node API
110
+ response = client._client.post(
111
+ f"/namespaces/{namespace}/nodes",
112
+ json={
113
+ "type": "PROJECT",
114
+ "name": project_name,
115
+ "slug": project_name,
116
+ "description": description or "",
117
+ }
118
+ )
119
+ response.raise_for_status()
120
+ result = response.json()
121
+
122
+ # Extract project info
123
+ project = result.get("project", {})
124
+ project_id = project.get("id")
125
+ project_slug = project.get("slug")
126
+
127
+ # Success message
128
+ console.print(f"[green]✓[/green] Project created successfully!")
129
+ console.print(f" Name: [bold]{project_slug}[/bold]")
130
+ console.print(f" Namespace: [bold]{namespace}[/bold]")
131
+ console.print(f" ID: {project_id}")
132
+ if description:
133
+ console.print(f" Description: {description}")
134
+ console.print(f"\n View at: https://dash.ml/@{namespace}/{project_slug}")
135
+
136
+ return 0
137
+
138
+ except Exception as e:
139
+ # Check if it's a 409 conflict (project already exists)
140
+ if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 409:
141
+ console.print(f"[yellow]⚠[/yellow] Project '[bold]{project_name}[/bold]' already exists in namespace '[bold]{namespace}[/bold]'")
142
+ return 0
143
+
144
+ console.print(f"[red]Error creating project:[/red] {e}")
145
+ return 1
@@ -615,6 +615,158 @@ class ExperimentDownloader:
615
615
  return {"success": False, "error": str(e), "bytes": 0}
616
616
 
617
617
 
618
+ # ============================================================================
619
+ # Track Download Command
620
+ # ============================================================================
621
+
622
+
623
+ def cmd_download_track(args: argparse.Namespace) -> int:
624
+ """Download track data from remote server."""
625
+ # Load configuration
626
+ config = Config()
627
+ remote_url = args.dash_url or config.remote_url
628
+ api_key = args.api_key or config.api_key
629
+
630
+ # Validate inputs
631
+ if not remote_url:
632
+ console.print("[red]Error:[/red] --dash-url is required (or set in config)")
633
+ return 1
634
+
635
+ if not args.tracks:
636
+ console.print("[red]Error:[/red] Track path is required")
637
+ return 1
638
+
639
+ # Parse track path: namespace/project/experiment/topic...
640
+ # The challenge is determining where experiment ends and topic begins
641
+ # Strategy: Try to find the experiment by checking server, working backwards from the end
642
+ track_path = args.tracks.strip("/")
643
+ parts = track_path.split("/")
644
+
645
+ if len(parts) < 4:
646
+ console.print(
647
+ "[red]Error:[/red] Track path must be in format: "
648
+ "'namespace/project/experiment/topic'"
649
+ )
650
+ console.print("Examples:")
651
+ console.print(" geyang/project/experiment/position")
652
+ console.print(" geyang/project/experiment/robot/position")
653
+ console.print(" geyang/project/folder/experiment/robot/camera/position")
654
+ return 1
655
+
656
+ # Extract namespace and project (always first 2 parts)
657
+ namespace = parts[0]
658
+ project = parts[1]
659
+
660
+ # Try to find the experiment by iterating backwards
661
+ # We assume experiment is a single name (not a path), and topic comes after it
662
+ experiment_name = None
663
+ topic = None
664
+
665
+ # Initialize client early to test experiment existence
666
+ try:
667
+ remote_client = RemoteClient(base_url=remote_url, namespace=namespace, api_key=api_key)
668
+ except Exception as e:
669
+ console.print(f"[red]Error:[/red] Failed to connect to server: {e}")
670
+ return 1
671
+
672
+ # Try different split points: experiment could be at index 2, 3, 4, etc.
673
+ # Topic is everything after the experiment
674
+ for exp_idx in range(2, len(parts) - 1): # Experiment must leave at least 1 part for topic
675
+ potential_exp_name = parts[exp_idx]
676
+ potential_topic = "/".join(parts[exp_idx + 1:])
677
+
678
+ # Try to fetch this experiment from server
679
+ try:
680
+ exp_data = remote_client.get_experiment_graphql(project, potential_exp_name, namespace)
681
+ if exp_data:
682
+ # Found the experiment!
683
+ experiment_name = potential_exp_name
684
+ topic = potential_topic
685
+ break
686
+ except Exception:
687
+ # This experiment doesn't exist, try next index
688
+ continue
689
+
690
+ if not experiment_name or not topic:
691
+ console.print(
692
+ f"[red]Error:[/red] Could not find valid experiment in path: {track_path}"
693
+ )
694
+ console.print("\nTried the following experiment names:")
695
+ for exp_idx in range(2, len(parts) - 1):
696
+ console.print(f" - {parts[exp_idx]}")
697
+ console.print(f"\nMake sure the experiment exists in project '{project}'")
698
+ return 1
699
+
700
+ console.print(f"[bold]Downloading track data...[/bold]")
701
+ console.print(f" Namespace: {namespace}")
702
+ console.print(f" Project: {project}")
703
+ console.print(f" Experiment: {experiment_name}")
704
+ console.print(f" Topic: {topic}")
705
+ console.print(f" Format: {args.format}")
706
+
707
+ try:
708
+ # Get experiment ID (we already validated it exists during path parsing)
709
+ exp_data = remote_client.get_experiment_graphql(project, experiment_name, namespace)
710
+ experiment_id = exp_data["id"]
711
+
712
+ # Download track data
713
+ console.print(f"\n[cyan]Fetching track data from server...[/cyan]")
714
+ track_data = remote_client.get_track_data(
715
+ experiment_id=experiment_id,
716
+ topic=topic,
717
+ format=args.format,
718
+ )
719
+
720
+ # Determine output filename
721
+ if args.output:
722
+ output_path = Path(args.output)
723
+ else:
724
+ # Generate filename from topic and format
725
+ safe_topic = topic.replace("/", "_")
726
+ extension = {
727
+ "json": "json",
728
+ "jsonl": "jsonl",
729
+ "parquet": "parquet",
730
+ "mcap": "mcap",
731
+ }.get(args.format, args.format)
732
+ output_path = Path(f"{safe_topic}.{extension}")
733
+
734
+ # Write to file
735
+ if args.format in ("jsonl", "parquet", "mcap"):
736
+ # Binary data
737
+ output_path.write_bytes(track_data)
738
+ else:
739
+ # JSON data
740
+ if isinstance(track_data, dict):
741
+ output_path.write_text(json.dumps(track_data, indent=2))
742
+ else:
743
+ output_path.write_text(str(track_data))
744
+
745
+ # Show success message
746
+ file_size = output_path.stat().st_size
747
+ console.print(
748
+ f"\n[green]✓ Track data downloaded successfully[/green]"
749
+ )
750
+ console.print(f" Output: {output_path}")
751
+ console.print(f" Size: {_format_bytes(file_size)}")
752
+ console.print(f" Format: {args.format}")
753
+
754
+ # Show entry count if available
755
+ if args.format == "json" and isinstance(track_data, dict):
756
+ entry_count = track_data.get("count", len(track_data.get("entries", [])))
757
+ if entry_count:
758
+ console.print(f" Entries: {entry_count}")
759
+
760
+ return 0
761
+
762
+ except Exception as e:
763
+ console.print(f"[red]Error downloading track data:[/red] {e}")
764
+ if args.verbose:
765
+ import traceback
766
+ console.print(traceback.format_exc())
767
+ return 1
768
+
769
+
618
770
  # ============================================================================
619
771
  # Main Command
620
772
  # ============================================================================
@@ -622,6 +774,10 @@ class ExperimentDownloader:
622
774
 
623
775
  def cmd_download(args: argparse.Namespace) -> int:
624
776
  """Execute download command."""
777
+ # Handle track download if --tracks is specified
778
+ if args.tracks:
779
+ return cmd_download_track(args)
780
+
625
781
  # Load configuration
626
782
  config = Config()
627
783
  remote_url = args.dash_url or config.remote_url
@@ -800,6 +956,27 @@ def add_parser(subparsers):
800
956
  help="Local storage directory (default: ./.dash)",
801
957
  )
802
958
 
959
+ # Track download mode
960
+ parser.add_argument(
961
+ "--tracks",
962
+ type=str,
963
+ help="Download track data from path (e.g., 'namespace/project/exp/robot/position')",
964
+ )
965
+ parser.add_argument(
966
+ "-f",
967
+ "--format",
968
+ type=str,
969
+ choices=["json", "jsonl", "parquet", "mcap"],
970
+ default="jsonl",
971
+ help="Track export format (default: jsonl)",
972
+ )
973
+ parser.add_argument(
974
+ "-o",
975
+ "--output",
976
+ type=str,
977
+ help="Output file path (default: auto-generated from topic)",
978
+ )
979
+
803
980
  # Remote configuration
804
981
  parser.add_argument(
805
982
  "--dash-url",
@@ -18,6 +18,101 @@ from ..config import Config
18
18
  console = Console()
19
19
 
20
20
 
21
+ def list_tracks(
22
+ remote_client: RemoteClient,
23
+ experiment_path: str,
24
+ topic_filter: Optional[str] = None,
25
+ output_json: bool = False,
26
+ verbose: bool = False
27
+ ) -> int:
28
+ """
29
+ List tracks in an experiment.
30
+
31
+ Args:
32
+ remote_client: Remote API client
33
+ experiment_path: Experiment path (namespace/project/experiment)
34
+ topic_filter: Optional topic filter (e.g., "robot/*")
35
+ output_json: Output as JSON
36
+ verbose: Show verbose output
37
+
38
+ Returns:
39
+ Exit code (0 for success, 1 for error)
40
+ """
41
+ # Parse experiment path
42
+ parts = experiment_path.strip("/").split("/")
43
+ if len(parts) < 3:
44
+ console.print("[red]Error:[/red] Experiment path must be 'namespace/project/experiment'")
45
+ return 1
46
+
47
+ namespace = parts[0]
48
+ project = parts[1]
49
+ experiment = parts[2]
50
+
51
+ try:
52
+ # Get experiment ID
53
+ exp_data = remote_client.get_experiment_graphql(project, experiment)
54
+ if not exp_data:
55
+ console.print(f"[red]Error:[/red] Experiment '{experiment}' not found in project '{project}'")
56
+ return 1
57
+
58
+ experiment_id = exp_data["id"]
59
+
60
+ # List tracks
61
+ tracks = remote_client.list_tracks(experiment_id, topic_filter)
62
+
63
+ if output_json:
64
+ # JSON output
65
+ output = {
66
+ "tracks": tracks,
67
+ "count": len(tracks),
68
+ "experiment": experiment_path
69
+ }
70
+ console.print(json.dumps(output, indent=2))
71
+ return 0
72
+
73
+ # Human-readable output
74
+ if not tracks:
75
+ console.print(f"[yellow]No tracks found[/yellow]")
76
+ return 0
77
+
78
+ console.print(f"\n[bold]Tracks in {experiment_path}[/bold]\n")
79
+
80
+ # Create table
81
+ table = Table(box=box.ROUNDED)
82
+ table.add_column("Topic", style="cyan", no_wrap=True)
83
+ table.add_column("Entries", justify="right")
84
+ table.add_column("Columns", style="dim")
85
+ table.add_column("Time Range", style="dim")
86
+
87
+ for track in tracks:
88
+ topic = track["topic"]
89
+ entries = str(track.get("totalEntries", 0))
90
+ columns = ", ".join(track.get("columns", [])[:5])
91
+ if len(track.get("columns", [])) > 5:
92
+ columns += f", ... (+{len(track['columns']) - 5})"
93
+
94
+ first_ts = track.get("firstTimestamp")
95
+ last_ts = track.get("lastTimestamp")
96
+ if first_ts is not None and last_ts is not None:
97
+ time_range = f"{first_ts:.3f} - {last_ts:.3f}"
98
+ else:
99
+ time_range = "N/A"
100
+
101
+ table.add_row(topic, entries, columns, time_range)
102
+
103
+ console.print(table)
104
+ console.print(f"\n[dim]Total tracks: {len(tracks)}[/dim]\n")
105
+
106
+ return 0
107
+
108
+ except Exception as e:
109
+ console.print(f"[red]Error listing tracks:[/red] {e}")
110
+ if verbose:
111
+ import traceback
112
+ console.print(traceback.format_exc())
113
+ return 1
114
+
115
+
21
116
  def _format_timestamp(iso_timestamp: str) -> str:
22
117
  """Format ISO timestamp as human-readable relative time."""
23
118
  try:
@@ -248,6 +343,45 @@ def cmd_list(args: argparse.Namespace) -> int:
248
343
  Returns:
249
344
  Exit code (0 for success, 1 for error)
250
345
  """
346
+ # Handle track listing if --tracks is specified
347
+ if args.tracks:
348
+ # Load config
349
+ config = Config()
350
+ remote_url = args.dash_url or config.remote_url
351
+ api_key = args.api_key or config.api_key
352
+
353
+ if not remote_url:
354
+ console.print("[red]Error:[/red] --dash-url is required (or set in config)")
355
+ return 1
356
+
357
+ if not args.project:
358
+ console.print("[red]Error:[/red] --project is required for listing tracks")
359
+ console.print("Example: ml-dash list --tracks --project namespace/project/experiment")
360
+ return 1
361
+
362
+ # Extract namespace from project path
363
+ parts = args.project.strip("/").split("/")
364
+ if len(parts) < 3:
365
+ console.print("[red]Error:[/red] For tracks, --project must be 'namespace/project/experiment'")
366
+ return 1
367
+
368
+ namespace = parts[0]
369
+
370
+ # Create remote client
371
+ try:
372
+ remote_client = RemoteClient(base_url=remote_url, namespace=namespace, api_key=api_key)
373
+ except Exception as e:
374
+ console.print(f"[red]Error connecting to remote:[/red] {e}")
375
+ return 1
376
+
377
+ return list_tracks(
378
+ remote_client=remote_client,
379
+ experiment_path=args.project,
380
+ topic_filter=args.topic_filter,
381
+ output_json=args.json,
382
+ verbose=args.verbose
383
+ )
384
+
251
385
  # Load config
252
386
  config = Config()
253
387
 
@@ -463,6 +597,18 @@ def add_parser(subparsers) -> None:
463
597
  help="Filter experiments by status")
464
598
  parser.add_argument("--tags", type=str, help="Filter experiments by tags (comma-separated)")
465
599
 
600
+ # Track listing mode
601
+ parser.add_argument(
602
+ "--tracks",
603
+ action="store_true",
604
+ help="List tracks in experiment (requires --project as 'namespace/project/experiment')"
605
+ )
606
+ parser.add_argument(
607
+ "--topic-filter",
608
+ type=str,
609
+ help="Filter tracks by topic (e.g., 'robot/*')"
610
+ )
611
+
466
612
  # Output options
467
613
  parser.add_argument("--json", action="store_true", help="Output as JSON")
468
614
  parser.add_argument("--detailed", action="store_true", help="Show detailed information")