ml-dash 0.6.6__py3-none-any.whl → 0.6.9__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.
@@ -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")
@@ -1,6 +1,7 @@
1
1
  """Profile command for ml-dash CLI - shows current user and configuration."""
2
2
 
3
3
  import json
4
+ import time
4
5
 
5
6
  from rich.console import Console
6
7
  from rich.panel import Panel
@@ -22,6 +23,83 @@ def add_parser(subparsers):
22
23
  action="store_true",
23
24
  help="Output as JSON",
24
25
  )
26
+ parser.add_argument(
27
+ "--refresh",
28
+ action="store_true",
29
+ help="Fetch fresh profile from server (not from cached token)",
30
+ )
31
+
32
+
33
+ def _fetch_fresh_profile(remote_url: str, token: str) -> dict:
34
+ """Fetch fresh user profile from the API server.
35
+
36
+ Args:
37
+ remote_url: API server URL
38
+ token: JWT authentication token
39
+
40
+ Returns:
41
+ User profile dict with username, email, name, etc.
42
+ """
43
+ try:
44
+ from ml_dash.client import RemoteClient
45
+
46
+ client = RemoteClient(remote_url, api_key=token)
47
+
48
+ # Query for full user profile
49
+ query = """
50
+ query GetUserProfile {
51
+ me {
52
+ id
53
+ username
54
+ name
55
+ email
56
+ }
57
+ }
58
+ """
59
+
60
+ result = client.graphql_query(query)
61
+ me = result.get("me", {})
62
+
63
+ if me:
64
+ return {
65
+ "sub": me.get("id"),
66
+ "username": me.get("username"),
67
+ "name": me.get("name"),
68
+ "email": me.get("email"),
69
+ }
70
+ except Exception as e:
71
+ # If API call fails, return None to fall back to token decoding
72
+ return None
73
+
74
+ return None
75
+
76
+
77
+ def _check_token_expiration(token_payload: dict) -> tuple[bool, str]:
78
+ """Check if token is expired or close to expiring.
79
+
80
+ Args:
81
+ token_payload: Decoded JWT payload
82
+
83
+ Returns:
84
+ Tuple of (is_expired, message)
85
+ """
86
+ exp = token_payload.get("exp")
87
+ if not exp:
88
+ return False, None
89
+
90
+ current_time = int(time.time())
91
+ time_left = exp - current_time
92
+
93
+ if time_left < 0:
94
+ return True, "[red]Token expired[/red]"
95
+ elif time_left < 86400: # Less than 1 day
96
+ hours_left = time_left // 3600
97
+ return False, f"[yellow]Token expires in {hours_left} hours[/yellow]"
98
+ else:
99
+ days_left = time_left // 86400
100
+ return False, f"Expires in {days_left} days"
101
+
102
+ return False, None
25
103
 
26
104
 
27
105
  def cmd_profile(args) -> int:
@@ -42,7 +120,33 @@ def cmd_profile(args) -> int:
42
120
 
43
121
  if token:
44
122
  info["authenticated"] = True
45
- info["user"] = decode_jwt_payload(token)
123
+
124
+ # Decode token payload for initial data and expiration check
125
+ token_payload = decode_jwt_payload(token)
126
+
127
+ # Check token expiration
128
+ is_expired, expiry_message = _check_token_expiration(token_payload)
129
+
130
+ if is_expired:
131
+ info["authenticated"] = False
132
+ info["error"] = "Token expired. Please run 'ml-dash login' to re-authenticate."
133
+ else:
134
+ # Fetch fresh profile from server if requested, or fall back to token
135
+ if args.refresh:
136
+ fresh_profile = _fetch_fresh_profile(config.remote_url, token)
137
+ if fresh_profile:
138
+ info["user"] = fresh_profile
139
+ info["source"] = "server"
140
+ else:
141
+ info["user"] = token_payload
142
+ info["source"] = "token"
143
+ info["warning"] = "Could not fetch fresh profile from server, using cached token data"
144
+ else:
145
+ info["user"] = token_payload
146
+ info["source"] = "token"
147
+
148
+ if expiry_message:
149
+ info["token_status"] = expiry_message
46
150
 
47
151
  if args.json:
48
152
  console.print_json(json.dumps(info))
@@ -50,10 +154,11 @@ def cmd_profile(args) -> int:
50
154
 
51
155
  # Rich display
52
156
  if not info["authenticated"]:
157
+ error_msg = info.get("error", "Not authenticated")
53
158
  console.print(
54
159
  Panel(
55
160
  f"[bold cyan]OS Username:[/bold cyan] {info.get('local_user')}\n\n"
56
- "[yellow]Not authenticated[/yellow]\n\n"
161
+ f"[yellow]{error_msg}[/yellow]\n\n"
57
162
  "Run [cyan]ml-dash login[/cyan] to authenticate.",
58
163
  title="[bold]ML-Dash Info[/bold]",
59
164
  border_style="yellow",
@@ -66,7 +171,6 @@ def cmd_profile(args) -> int:
66
171
  table.add_column("Key", style="bold cyan")
67
172
  table.add_column("Value")
68
173
 
69
- # table.add_row("OS Username", info.get("local_user"))
70
174
  user = info.get("user", {})
71
175
  if user.get("username"):
72
176
  table.add_row("Username", user["username"])
@@ -78,12 +182,43 @@ def cmd_profile(args) -> int:
78
182
  if user.get("email"):
79
183
  table.add_row("Email", user["email"])
80
184
  table.add_row("Remote", info.get("remote_url") or "https://api.dash.ml")
81
- if info.get("token_expires"):
82
- table.add_row("Token Expires", info["token_expires"])
185
+
186
+ # Show token status (expiration)
187
+ if info.get("token_status"):
188
+ table.add_row("Token Status", info["token_status"])
189
+
190
+ # Show data source
191
+ source = info.get("source", "token")
192
+ if source == "server":
193
+ table.add_row("Data Source", "[green]Server (Fresh)[/green]")
194
+ else:
195
+ table.add_row("Data Source", "[yellow]Token (Cached)[/yellow]")
196
+
197
+ # Show warning if any
198
+ warning_text = None
199
+ if info.get("warning"):
200
+ warning_text = f"\n[yellow]⚠ {info['warning']}[/yellow]"
201
+
202
+ # Show tip for refreshing
203
+ if source == "token":
204
+ tip_text = "\n[dim]Tip: Use --refresh to fetch fresh data from server[/dim]"
205
+ else:
206
+ tip_text = None
207
+
208
+ # Build panel content
209
+ panel_content = table
210
+ if warning_text or tip_text:
211
+ from rich.console import Group
212
+ items = [table]
213
+ if warning_text:
214
+ items.append(warning_text)
215
+ if tip_text:
216
+ items.append(tip_text)
217
+ panel_content = Group(*items)
83
218
 
84
219
  console.print(
85
220
  Panel(
86
- table,
221
+ panel_content,
87
222
  title="[bold green]✓ Authenticated[/bold green]",
88
223
  border_style="green",
89
224
  )
@@ -148,6 +148,18 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
148
148
  help="JWT token for authentication (optional - auto-loads from 'ml-dash login' if not provided)",
149
149
  )
150
150
 
151
+ # Track upload mode
152
+ parser.add_argument(
153
+ "--tracks",
154
+ type=str,
155
+ help="Upload track data file (e.g., robot_position.jsonl). Requires --remote-path.",
156
+ )
157
+ parser.add_argument(
158
+ "--remote-path",
159
+ type=str,
160
+ help="Remote path for track (e.g., 'namespace/project/exp/robot/position')",
161
+ )
162
+
151
163
  """
152
164
 
153
165
  cd .dash/geyang
@@ -1079,6 +1091,121 @@ class ExperimentUploader:
1079
1091
  return total_uploaded
1080
1092
 
1081
1093
 
1094
+ def cmd_upload_track(args: argparse.Namespace) -> int:
1095
+ """Upload track data file to remote server."""
1096
+ from datetime import datetime
1097
+
1098
+ # Load config
1099
+ config = Config()
1100
+ remote_url = args.dash_url or config.remote_url
1101
+ api_key = args.api_key or config.api_key
1102
+
1103
+ if not remote_url:
1104
+ console.print("[red]Error:[/red] --dash-url is required (or set in config)")
1105
+ return 1
1106
+
1107
+ if not args.tracks or not args.remote_path:
1108
+ console.print("[red]Error:[/red] Both --tracks and --remote-path are required for track upload")
1109
+ console.print("Usage: ml-dash upload --tracks <local-file> --remote-path namespace/project/exp/topic")
1110
+ return 1
1111
+
1112
+ # Parse local file path
1113
+ local_file = Path(args.tracks)
1114
+ if not local_file.exists():
1115
+ console.print(f"[red]Error:[/red] File not found: {local_file}")
1116
+ return 1
1117
+
1118
+ # Parse remote path: namespace/project/.../experiment/topic
1119
+ remote_path = args.remote_path.strip("/")
1120
+ parts = remote_path.split("/")
1121
+
1122
+ if len(parts) < 4:
1123
+ console.print(
1124
+ "[red]Error:[/red] Remote path must be: 'namespace/project/experiment/topic'"
1125
+ )
1126
+ console.print("Example: geyang/project/exp1/robot/position")
1127
+ return 1
1128
+
1129
+ namespace = parts[0]
1130
+ project = parts[1]
1131
+ experiment_name = parts[-2] # Second to last is experiment
1132
+ topic = "/".join(parts[-1:]) # Last part is topic (could be multi-level)
1133
+
1134
+ console.print(f"[bold]Uploading track data...[/bold]")
1135
+ console.print(f" Local file: {local_file}")
1136
+ console.print(f" Namespace: {namespace}")
1137
+ console.print(f" Project: {project}")
1138
+ console.print(f" Experiment: {experiment_name}")
1139
+ console.print(f" Topic: {topic}")
1140
+
1141
+ try:
1142
+ # Initialize remote client
1143
+ remote_client = RemoteClient(base_url=remote_url, namespace=namespace, api_key=api_key)
1144
+
1145
+ # Get experiment ID
1146
+ exp_data = remote_client.get_experiment_graphql(project, experiment_name)
1147
+ if not exp_data:
1148
+ console.print(
1149
+ f"[red]Error:[/red] Experiment '{experiment_name}' not found in project '{project}'"
1150
+ )
1151
+ return 1
1152
+
1153
+ experiment_id = exp_data["id"]
1154
+
1155
+ # Read local file (assume JSONL format)
1156
+ console.print(f"\n[cyan]Reading local file...[/cyan]")
1157
+ entries = []
1158
+
1159
+ with open(local_file, 'r') as f:
1160
+ for line_num, line in enumerate(f, 1):
1161
+ if line.strip():
1162
+ try:
1163
+ entry = json.loads(line)
1164
+ if "timestamp" not in entry:
1165
+ console.print(f"[yellow]Warning:[/yellow] Line {line_num} missing timestamp, skipping")
1166
+ continue
1167
+ entries.append(entry)
1168
+ except json.JSONDecodeError as e:
1169
+ console.print(f"[yellow]Warning:[/yellow] Line {line_num} invalid JSON: {e}")
1170
+ continue
1171
+
1172
+ if not entries:
1173
+ console.print("[red]Error:[/red] No valid entries found in file")
1174
+ return 1
1175
+
1176
+ console.print(f" Found {len(entries)} entries")
1177
+
1178
+ # Upload in batches
1179
+ console.print(f"\n[cyan]Uploading to server...[/cyan]")
1180
+ batch_size = 1000
1181
+ total_uploaded = 0
1182
+
1183
+ for i in range(0, len(entries), batch_size):
1184
+ batch = entries[i:i + batch_size]
1185
+ remote_client.append_batch_to_track(
1186
+ experiment_id=experiment_id,
1187
+ topic=topic,
1188
+ entries=batch
1189
+ )
1190
+ total_uploaded += len(batch)
1191
+ console.print(f" Uploaded {total_uploaded}/{len(entries)} entries")
1192
+
1193
+ # Success
1194
+ console.print(f"\n[green]✓ Track data uploaded successfully[/green]")
1195
+ console.print(f" Total entries: {total_uploaded}")
1196
+ console.print(f" Topic: {topic}")
1197
+ console.print(f" Experiment: {namespace}/{project}/{experiment_name}")
1198
+
1199
+ return 0
1200
+
1201
+ except Exception as e:
1202
+ console.print(f"[red]Error uploading track data:[/red] {e}")
1203
+ if args.verbose:
1204
+ import traceback
1205
+ console.print(traceback.format_exc())
1206
+ return 1
1207
+
1208
+
1082
1209
  def cmd_upload(args: argparse.Namespace) -> int:
1083
1210
  """
1084
1211
  Execute upload command.
@@ -1089,6 +1216,10 @@ def cmd_upload(args: argparse.Namespace) -> int:
1089
1216
  Returns:
1090
1217
  Exit code (0 for success, 1 for error)
1091
1218
  """
1219
+ # Handle track upload if --tracks is specified
1220
+ if args.tracks:
1221
+ return cmd_upload_track(args)
1222
+
1092
1223
  # Load config
1093
1224
  config = Config()
1094
1225