ml-dash 0.5.8__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.
- ml_dash/__init__.py +35 -9
- ml_dash/auth/__init__.py +51 -0
- ml_dash/auth/constants.py +10 -0
- ml_dash/auth/device_flow.py +237 -0
- ml_dash/auth/device_secret.py +49 -0
- ml_dash/auth/exceptions.py +31 -0
- ml_dash/auth/token_storage.py +262 -0
- ml_dash/auto_start.py +37 -14
- ml_dash/cli.py +14 -2
- ml_dash/cli_commands/download.py +10 -38
- ml_dash/cli_commands/list.py +10 -34
- ml_dash/cli_commands/login.py +225 -0
- ml_dash/cli_commands/logout.py +54 -0
- ml_dash/cli_commands/upload.py +3 -53
- ml_dash/client.py +67 -34
- ml_dash/config.py +15 -1
- ml_dash/experiment.py +151 -55
- ml_dash/files.py +97 -0
- ml_dash/metric.py +192 -3
- ml_dash/params.py +92 -3
- ml_dash/remote_auto_start.py +55 -0
- ml_dash/storage.py +366 -235
- {ml_dash-0.5.8.dist-info → ml_dash-0.6.0.dist-info}/METADATA +5 -1
- ml_dash-0.6.0.dist-info/RECORD +29 -0
- ml_dash-0.5.8.dist-info/RECORD +0 -20
- {ml_dash-0.5.8.dist-info → ml_dash-0.6.0.dist-info}/WHEEL +0 -0
- {ml_dash-0.5.8.dist-info → ml_dash-0.6.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
ml_dash/cli_commands/upload.py
CHANGED
|
@@ -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 (
|
|
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 >
|
|
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
|
-
|
|
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.
|
|
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
|
|
644
|
+
def list_projects_graphql(self) -> List[Dict[str, Any]]:
|
|
612
645
|
"""
|
|
613
|
-
List all projects
|
|
646
|
+
List all projects via GraphQL.
|
|
614
647
|
|
|
615
|
-
|
|
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
|
|
626
|
-
projects
|
|
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, {
|
|
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($
|
|
642
|
-
experiments(
|
|
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,
|
|
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($
|
|
675
|
-
experiments(
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
"
|
|
717
|
-
|
|
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,
|
|
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($
|
|
740
|
-
experiment(
|
|
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
|
-
|
|
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
|
|
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()
|