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.
- ml_dash/buffer.py +735 -0
- ml_dash/cli_commands/download.py +177 -0
- ml_dash/cli_commands/list.py +146 -0
- ml_dash/cli_commands/profile.py +141 -6
- ml_dash/cli_commands/upload.py +131 -0
- ml_dash/client.py +265 -20
- ml_dash/experiment.py +286 -126
- ml_dash/files.py +228 -70
- ml_dash/storage.py +403 -0
- ml_dash/track.py +263 -0
- {ml_dash-0.6.6.dist-info → ml_dash-0.6.9.dist-info}/METADATA +81 -5
- {ml_dash-0.6.6.dist-info → ml_dash-0.6.9.dist-info}/RECORD +14 -12
- {ml_dash-0.6.6.dist-info → ml_dash-0.6.9.dist-info}/WHEEL +0 -0
- {ml_dash-0.6.6.dist-info → ml_dash-0.6.9.dist-info}/entry_points.txt +0 -0
ml_dash/cli_commands/download.py
CHANGED
|
@@ -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",
|
ml_dash/cli_commands/list.py
CHANGED
|
@@ -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")
|
ml_dash/cli_commands/profile.py
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
221
|
+
panel_content,
|
|
87
222
|
title="[bold green]✓ Authenticated[/bold green]",
|
|
88
223
|
border_style="green",
|
|
89
224
|
)
|
ml_dash/cli_commands/upload.py
CHANGED
|
@@ -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
|
|