ml-dash 0.5.9__py3-none-any.whl → 0.6.0__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.
@@ -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
@@ -108,43 +108,6 @@ class UploadState:
108
108
  return None
109
109
 
110
110
 
111
- def generate_api_key_from_username(user_name: str) -> str:
112
- """
113
- Generate a deterministic API key (JWT) from username.
114
-
115
- This is a temporary solution until proper user authentication is implemented.
116
- Generates a unique user ID from the username and creates a JWT token.
117
-
118
- Args:
119
- user_name: Username to generate API key from
120
-
121
- Returns:
122
- JWT token string
123
- """
124
- import hashlib
125
- import time
126
- import jwt
127
-
128
- # Generate deterministic user ID from username (first 10 digits of SHA256 hash)
129
- user_id = str(int(hashlib.sha256(user_name.encode()).hexdigest()[:16], 16))[:10]
130
-
131
- # JWT payload
132
- payload = {
133
- "userId": user_id,
134
- "userName": user_name,
135
- "iat": int(time.time()),
136
- "exp": int(time.time()) + (30 * 24 * 60 * 60) # 30 days expiration
137
- }
138
-
139
- # Secret key for signing (should match server's JWT_SECRET)
140
- secret = "your-secret-key-change-this-in-production"
141
-
142
- # Generate JWT
143
- token = jwt.encode(payload, secret, algorithm="HS256")
144
-
145
- return token
146
-
147
-
148
111
  def add_parser(subparsers) -> argparse.ArgumentParser:
149
112
  """Add upload command parser."""
150
113
  parser = subparsers.add_parser(
@@ -170,12 +133,7 @@ def add_parser(subparsers) -> argparse.ArgumentParser:
170
133
  parser.add_argument(
171
134
  "--api-key",
172
135
  type=str,
173
- help="JWT token for authentication (required unless --username or config is set)",
174
- )
175
- parser.add_argument(
176
- "--username",
177
- type=str,
178
- help="Username for authentication (generates API key automatically)",
136
+ help="JWT token for authentication (optional - auto-loads from 'ml-dash login' if not provided)",
179
137
  )
180
138
 
181
139
  # Scope control
@@ -995,18 +953,10 @@ def cmd_upload(args: argparse.Namespace) -> int:
995
953
  console.print("[red]Error:[/red] --remote URL is required (or set in config)")
996
954
  return 1
997
955
 
998
- # Get API key (command line > config > generate from username)
956
+ # Get API key (command line > config > auto-load from storage)
957
+ # RemoteClient will auto-load from storage if api_key is None
999
958
  api_key = args.api_key or config.api_key
1000
959
 
1001
- # If no API key, try to generate from username
1002
- if not api_key:
1003
- if args.username:
1004
- console.print(f"[dim]Generating API key from username: {args.username}[/dim]")
1005
- api_key = generate_api_key_from_username(args.username)
1006
- else:
1007
- console.print("[red]Error:[/red] --api-key or --username is required (or set in config)")
1008
- return 1
1009
-
1010
960
  # Validate experiment filter requires project
1011
961
  if args.experiment and not args.project:
1012
962
  console.print("[red]Error:[/red] --experiment requires --project")
ml_dash/client.py CHANGED
@@ -9,16 +9,40 @@ import httpx
9
9
  class RemoteClient:
10
10
  """Client for communicating with ML-Dash server."""
11
11
 
12
- def __init__(self, base_url: str, api_key: str):
12
+ def __init__(self, base_url: str, api_key: Optional[str] = None):
13
13
  """
14
14
  Initialize remote client.
15
15
 
16
16
  Args:
17
17
  base_url: Base URL of ML-Dash server (e.g., "http://localhost:3000")
18
- api_key: JWT token for authentication
18
+ api_key: JWT token for authentication (optional - auto-loads from storage if not provided)
19
+
20
+ Raises:
21
+ AuthenticationError: If no api_key provided and no token found in storage
19
22
  """
20
- self.base_url = base_url.rstrip("/")
23
+ # Store original base URL for GraphQL (no /api prefix)
24
+ self.graphql_base_url = base_url.rstrip("/")
25
+
26
+ # Add /api prefix to base URL for REST API calls
27
+ self.base_url = base_url.rstrip("/") + "/api"
28
+
29
+ # If no api_key provided, try to load from storage
30
+ if not api_key:
31
+ from .auth.token_storage import get_token_storage
32
+ from .auth.exceptions import AuthenticationError
33
+
34
+ storage = get_token_storage()
35
+ api_key = storage.load("ml-dash-token")
36
+
37
+ if not api_key:
38
+ raise AuthenticationError(
39
+ "Not authenticated. Run 'ml-dash login' to authenticate, "
40
+ "or provide an explicit api_key parameter."
41
+ )
42
+
21
43
  self.api_key = api_key
44
+
45
+ # REST API client (with /api prefix)
22
46
  self._client = httpx.Client(
23
47
  base_url=self.base_url,
24
48
  headers={
@@ -29,6 +53,15 @@ class RemoteClient:
29
53
  timeout=30.0,
30
54
  )
31
55
 
56
+ # GraphQL client (without /api prefix)
57
+ self._graphql_client = httpx.Client(
58
+ base_url=self.graphql_base_url,
59
+ headers={
60
+ "Authorization": f"Bearer {api_key}",
61
+ },
62
+ timeout=30.0,
63
+ )
64
+
32
65
  def create_or_update_experiment(
33
66
  self,
34
67
  project: str,
@@ -596,7 +629,7 @@ class RemoteClient:
596
629
  httpx.HTTPStatusError: If request fails
597
630
  Exception: If GraphQL returns errors
598
631
  """
599
- response = self._client.post(
632
+ response = self._graphql_client.post(
600
633
  "/graphql",
601
634
  json={"query": query, "variables": variables or {}}
602
635
  )
@@ -608,12 +641,11 @@ class RemoteClient:
608
641
 
609
642
  return result.get("data", {})
610
643
 
611
- def list_projects_graphql(self, namespace_slug: str) -> List[Dict[str, Any]]:
644
+ def list_projects_graphql(self) -> List[Dict[str, Any]]:
612
645
  """
613
- List all projects in a namespace via GraphQL.
646
+ List all projects via GraphQL.
614
647
 
615
- Args:
616
- namespace_slug: Namespace slug
648
+ Namespace is automatically inferred from JWT token on the server.
617
649
 
618
650
  Returns:
619
651
  List of project dicts with experimentCount
@@ -622,8 +654,8 @@ class RemoteClient:
622
654
  httpx.HTTPStatusError: If request fails
623
655
  """
624
656
  query = """
625
- query Projects($namespaceSlug: String!) {
626
- projects(namespaceSlug: $namespaceSlug) {
657
+ query Projects {
658
+ projects {
627
659
  id
628
660
  name
629
661
  slug
@@ -632,35 +664,33 @@ class RemoteClient:
632
664
  }
633
665
  }
634
666
  """
635
- result = self.graphql_query(query, {"namespaceSlug": namespace_slug})
667
+ result = self.graphql_query(query, {})
636
668
  projects = result.get("projects", [])
637
669
 
638
670
  # For each project, count experiments
639
671
  for project in projects:
640
672
  exp_query = """
641
- query ExperimentsCount($namespaceSlug: String!, $projectSlug: String!) {
642
- experiments(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug) {
673
+ query ExperimentsCount($projectSlug: String!) {
674
+ experiments(projectSlug: $projectSlug) {
643
675
  id
644
676
  }
645
677
  }
646
678
  """
647
- exp_result = self.graphql_query(exp_query, {
648
- "namespaceSlug": namespace_slug,
649
- "projectSlug": project['slug']
650
- })
679
+ exp_result = self.graphql_query(exp_query, {"projectSlug": project['slug']})
651
680
  experiments = exp_result.get("experiments", [])
652
681
  project['experimentCount'] = len(experiments)
653
682
 
654
683
  return projects
655
684
 
656
685
  def list_experiments_graphql(
657
- self, namespace_slug: str, project_slug: str, status: Optional[str] = None
686
+ self, project_slug: str, status: Optional[str] = None
658
687
  ) -> List[Dict[str, Any]]:
659
688
  """
660
689
  List experiments in a project via GraphQL.
661
690
 
691
+ Namespace is automatically inferred from JWT token on the server.
692
+
662
693
  Args:
663
- namespace_slug: Namespace slug
664
694
  project_slug: Project slug
665
695
  status: Optional experiment status filter (RUNNING, COMPLETED, FAILED, CANCELLED)
666
696
 
@@ -671,8 +701,8 @@ class RemoteClient:
671
701
  httpx.HTTPStatusError: If request fails
672
702
  """
673
703
  query = """
674
- query Experiments($namespaceSlug: String!, $projectSlug: String!, $status: ExperimentStatus) {
675
- experiments(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug, status: $status) {
704
+ query Experiments($projectSlug: String!, $status: ExperimentStatus) {
705
+ experiments(projectSlug: $projectSlug, status: $status) {
676
706
  id
677
707
  name
678
708
  description
@@ -711,21 +741,22 @@ class RemoteClient:
711
741
  }
712
742
  }
713
743
  """
714
- result = self.graphql_query(query, {
715
- "namespaceSlug": namespace_slug,
716
- "projectSlug": project_slug,
717
- "status": status
718
- })
744
+ variables = {"projectSlug": project_slug}
745
+ if status is not None:
746
+ variables["status"] = status
747
+
748
+ result = self.graphql_query(query, variables)
719
749
  return result.get("experiments", [])
720
750
 
721
751
  def get_experiment_graphql(
722
- self, namespace_slug: str, project_slug: str, experiment_name: str
752
+ self, project_slug: str, experiment_name: str
723
753
  ) -> Optional[Dict[str, Any]]:
724
754
  """
725
755
  Get a single experiment via GraphQL.
726
756
 
757
+ Namespace is automatically inferred from JWT token on the server.
758
+
727
759
  Args:
728
- namespace_slug: Namespace slug
729
760
  project_slug: Project slug
730
761
  experiment_name: Experiment name
731
762
 
@@ -736,8 +767,8 @@ class RemoteClient:
736
767
  httpx.HTTPStatusError: If request fails
737
768
  """
738
769
  query = """
739
- query Experiment($namespaceSlug: String!, $projectSlug: String!, $experimentName: String!) {
740
- experiment(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug, experimentName: $experimentName) {
770
+ query Experiment($projectSlug: String!, $experimentName: String!) {
771
+ experiment(projectSlug: $projectSlug, experimentName: $experimentName) {
741
772
  id
742
773
  name
743
774
  description
@@ -774,11 +805,12 @@ class RemoteClient:
774
805
  }
775
806
  }
776
807
  """
777
- result = self.graphql_query(query, {
778
- "namespaceSlug": namespace_slug,
808
+ variables = {
779
809
  "projectSlug": project_slug,
780
810
  "experimentName": experiment_name
781
- })
811
+ }
812
+
813
+ result = self.graphql_query(query, variables)
782
814
  return result.get("experiment")
783
815
 
784
816
  def download_file_streaming(
@@ -942,8 +974,9 @@ class RemoteClient:
942
974
  return response.json()
943
975
 
944
976
  def close(self):
945
- """Close the HTTP client."""
977
+ """Close the HTTP clients."""
946
978
  self._client.close()
979
+ self._graphql_client.close()
947
980
 
948
981
  def __enter__(self):
949
982
  """Context manager entry."""
ml_dash/config.py CHANGED
@@ -91,7 +91,7 @@ class Config:
91
91
  @property
92
92
  def remote_url(self) -> Optional[str]:
93
93
  """Get default remote URL."""
94
- return self.get("remote_url")
94
+ return self.get("remote_url", "https://api.dash.ml")
95
95
 
96
96
  @remote_url.setter
97
97
  def remote_url(self, url: str):
@@ -117,3 +117,17 @@ class Config:
117
117
  def batch_size(self, size: int):
118
118
  """Set default batch size."""
119
119
  self.set("default_batch_size", size)
120
+
121
+ @property
122
+ def device_secret(self) -> Optional[str]:
123
+ """Get device secret for OAuth device flow."""
124
+ return self.get("device_secret")
125
+
126
+ @device_secret.setter
127
+ def device_secret(self, secret: str):
128
+ """Set device secret."""
129
+ self.set("device_secret", secret)
130
+
131
+
132
+ # Global config instance
133
+ config = Config()