ml-dash 0.6.0__py3-none-any.whl → 0.6.2__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 +37 -63
- ml_dash/auth/token_storage.py +267 -226
- ml_dash/auto_start.py +30 -30
- ml_dash/cli.py +16 -2
- ml_dash/cli_commands/api.py +165 -0
- ml_dash/cli_commands/download.py +757 -667
- ml_dash/cli_commands/list.py +146 -13
- ml_dash/cli_commands/login.py +190 -183
- ml_dash/cli_commands/profile.py +92 -0
- ml_dash/cli_commands/upload.py +1291 -1141
- ml_dash/client.py +122 -34
- ml_dash/config.py +119 -119
- ml_dash/experiment.py +1242 -995
- ml_dash/files.py +1051 -340
- ml_dash/log.py +7 -7
- ml_dash/metric.py +359 -100
- ml_dash/params.py +6 -6
- ml_dash/remote_auto_start.py +20 -17
- ml_dash/run.py +231 -0
- ml_dash/snowflake.py +173 -0
- ml_dash/storage.py +1051 -1079
- {ml_dash-0.6.0.dist-info → ml_dash-0.6.2.dist-info}/METADATA +45 -20
- ml_dash-0.6.2.dist-info/RECORD +33 -0
- ml_dash-0.6.0.dist-info/RECORD +0 -29
- {ml_dash-0.6.0.dist-info → ml_dash-0.6.2.dist-info}/WHEEL +0 -0
- {ml_dash-0.6.0.dist-info → ml_dash-0.6.2.dist-info}/entry_points.txt +0 -0
ml_dash/client.py
CHANGED
|
@@ -17,8 +17,9 @@ class RemoteClient:
|
|
|
17
17
|
base_url: Base URL of ML-Dash server (e.g., "http://localhost:3000")
|
|
18
18
|
api_key: JWT token for authentication (optional - auto-loads from storage if not provided)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
Note:
|
|
21
|
+
If no api_key is provided, token will be loaded from storage on first API call.
|
|
22
|
+
If still not found, AuthenticationError will be raised at that time.
|
|
22
23
|
"""
|
|
23
24
|
# Store original base URL for GraphQL (no /api prefix)
|
|
24
25
|
self.graphql_base_url = base_url.rstrip("/")
|
|
@@ -29,38 +30,52 @@ class RemoteClient:
|
|
|
29
30
|
# If no api_key provided, try to load from storage
|
|
30
31
|
if not api_key:
|
|
31
32
|
from .auth.token_storage import get_token_storage
|
|
32
|
-
from .auth.exceptions import AuthenticationError
|
|
33
33
|
|
|
34
34
|
storage = get_token_storage()
|
|
35
35
|
api_key = storage.load("ml-dash-token")
|
|
36
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
|
-
|
|
43
37
|
self.api_key = api_key
|
|
38
|
+
self._rest_client = None
|
|
39
|
+
self._gql_client = None
|
|
44
40
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
self.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
41
|
+
def _ensure_authenticated(self):
|
|
42
|
+
"""Check if authenticated, raise error if not."""
|
|
43
|
+
if not self.api_key:
|
|
44
|
+
from .auth.exceptions import AuthenticationError
|
|
45
|
+
raise AuthenticationError(
|
|
46
|
+
"Not authenticated. Run 'ml-dash login' to authenticate, "
|
|
47
|
+
"or provide an explicit api_key parameter."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def _client(self):
|
|
52
|
+
"""Lazy REST API client (with /api prefix)."""
|
|
53
|
+
if self._rest_client is None:
|
|
54
|
+
self._ensure_authenticated()
|
|
55
|
+
self._rest_client = httpx.Client(
|
|
56
|
+
base_url=self.base_url,
|
|
57
|
+
headers={
|
|
58
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
59
|
+
# Note: Don't set Content-Type here as default
|
|
60
|
+
# It will be set per-request (json or multipart)
|
|
61
|
+
},
|
|
62
|
+
timeout=30.0,
|
|
63
|
+
)
|
|
64
|
+
return self._rest_client
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def _graphql_client(self):
|
|
68
|
+
"""Lazy GraphQL client (without /api prefix)."""
|
|
69
|
+
if self._gql_client is None:
|
|
70
|
+
self._ensure_authenticated()
|
|
71
|
+
self._gql_client = httpx.Client(
|
|
72
|
+
base_url=self.graphql_base_url,
|
|
73
|
+
headers={
|
|
74
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
75
|
+
},
|
|
76
|
+
timeout=30.0,
|
|
77
|
+
)
|
|
78
|
+
return self._gql_client
|
|
64
79
|
|
|
65
80
|
def create_or_update_experiment(
|
|
66
81
|
self,
|
|
@@ -69,7 +84,7 @@ class RemoteClient:
|
|
|
69
84
|
description: Optional[str] = None,
|
|
70
85
|
tags: Optional[List[str]] = None,
|
|
71
86
|
bindrs: Optional[List[str]] = None,
|
|
72
|
-
|
|
87
|
+
prefix: Optional[str] = None,
|
|
73
88
|
write_protected: bool = False,
|
|
74
89
|
metadata: Optional[Dict[str, Any]] = None,
|
|
75
90
|
) -> Dict[str, Any]:
|
|
@@ -78,16 +93,16 @@ class RemoteClient:
|
|
|
78
93
|
|
|
79
94
|
Args:
|
|
80
95
|
project: Project name
|
|
81
|
-
name: Experiment name
|
|
96
|
+
name: Experiment name (last segment of prefix)
|
|
82
97
|
description: Optional description
|
|
83
98
|
tags: Optional list of tags
|
|
84
99
|
bindrs: Optional list of bindrs
|
|
85
|
-
|
|
100
|
+
prefix: Full prefix path sent to backend for folder hierarchy creation
|
|
86
101
|
write_protected: If True, experiment becomes immutable
|
|
87
102
|
metadata: Optional metadata dict
|
|
88
103
|
|
|
89
104
|
Returns:
|
|
90
|
-
Response dict with experiment, project,
|
|
105
|
+
Response dict with experiment, project, and namespace data
|
|
91
106
|
|
|
92
107
|
Raises:
|
|
93
108
|
httpx.HTTPStatusError: If request fails
|
|
@@ -102,12 +117,12 @@ class RemoteClient:
|
|
|
102
117
|
payload["tags"] = tags
|
|
103
118
|
if bindrs is not None:
|
|
104
119
|
payload["bindrs"] = bindrs
|
|
105
|
-
if folder is not None:
|
|
106
|
-
payload["folder"] = folder
|
|
107
120
|
if write_protected:
|
|
108
121
|
payload["writeProtected"] = write_protected
|
|
109
122
|
if metadata is not None:
|
|
110
123
|
payload["metadata"] = metadata
|
|
124
|
+
if prefix is not None:
|
|
125
|
+
payload["prefix"] = prefix
|
|
111
126
|
|
|
112
127
|
response = self._client.post(
|
|
113
128
|
f"/projects/{project}/experiments",
|
|
@@ -713,6 +728,9 @@ class RemoteClient:
|
|
|
713
728
|
metadata
|
|
714
729
|
project {
|
|
715
730
|
slug
|
|
731
|
+
namespace {
|
|
732
|
+
slug
|
|
733
|
+
}
|
|
716
734
|
}
|
|
717
735
|
logMetadata {
|
|
718
736
|
totalLogs
|
|
@@ -777,6 +795,9 @@ class RemoteClient:
|
|
|
777
795
|
metadata
|
|
778
796
|
project {
|
|
779
797
|
slug
|
|
798
|
+
namespace {
|
|
799
|
+
slug
|
|
800
|
+
}
|
|
780
801
|
}
|
|
781
802
|
logMetadata {
|
|
782
803
|
totalLogs
|
|
@@ -813,6 +834,73 @@ class RemoteClient:
|
|
|
813
834
|
result = self.graphql_query(query, variables)
|
|
814
835
|
return result.get("experiment")
|
|
815
836
|
|
|
837
|
+
def search_experiments_graphql(self, pattern: str) -> List[Dict[str, Any]]:
|
|
838
|
+
"""
|
|
839
|
+
Search experiments using glob pattern via GraphQL.
|
|
840
|
+
|
|
841
|
+
Pattern format: namespace/project/experiment
|
|
842
|
+
Supports wildcards: *, ?, [0-9], [a-z], etc.
|
|
843
|
+
|
|
844
|
+
Args:
|
|
845
|
+
pattern: Glob pattern (e.g., "tom*/tutorials/*", "*/project-?/exp*")
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
List of experiment dicts matching the pattern
|
|
849
|
+
|
|
850
|
+
Raises:
|
|
851
|
+
httpx.HTTPStatusError: If request fails
|
|
852
|
+
|
|
853
|
+
Examples:
|
|
854
|
+
>>> client.search_experiments_graphql("tom*/tutorials/*")
|
|
855
|
+
>>> client.search_experiments_graphql("*/my-project/baseline*")
|
|
856
|
+
"""
|
|
857
|
+
query = """
|
|
858
|
+
query SearchExperiments($pattern: String!) {
|
|
859
|
+
searchExperiments(pattern: $pattern) {
|
|
860
|
+
id
|
|
861
|
+
name
|
|
862
|
+
description
|
|
863
|
+
tags
|
|
864
|
+
status
|
|
865
|
+
startedAt
|
|
866
|
+
endedAt
|
|
867
|
+
metadata
|
|
868
|
+
project {
|
|
869
|
+
id
|
|
870
|
+
slug
|
|
871
|
+
name
|
|
872
|
+
namespace {
|
|
873
|
+
id
|
|
874
|
+
slug
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
logMetadata {
|
|
878
|
+
totalLogs
|
|
879
|
+
}
|
|
880
|
+
metrics {
|
|
881
|
+
name
|
|
882
|
+
metricMetadata {
|
|
883
|
+
totalDataPoints
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
files {
|
|
887
|
+
id
|
|
888
|
+
filename
|
|
889
|
+
path
|
|
890
|
+
contentType
|
|
891
|
+
sizeBytes
|
|
892
|
+
checksum
|
|
893
|
+
description
|
|
894
|
+
tags
|
|
895
|
+
metadata
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
"""
|
|
900
|
+
variables = {"pattern": pattern}
|
|
901
|
+
result = self.graphql_query(query, variables)
|
|
902
|
+
return result.get("searchExperiments", [])
|
|
903
|
+
|
|
816
904
|
def download_file_streaming(
|
|
817
905
|
self, experiment_id: str, file_id: str, dest_path: str
|
|
818
906
|
) -> str:
|
ml_dash/config.py
CHANGED
|
@@ -1,132 +1,132 @@
|
|
|
1
1
|
"""Configuration file management for ML-Dash CLI."""
|
|
2
2
|
|
|
3
|
-
from pathlib import Path
|
|
4
3
|
import json
|
|
5
|
-
from
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class Config:
|
|
9
|
+
"""
|
|
10
|
+
Manages ML-Dash CLI configuration file.
|
|
11
|
+
|
|
12
|
+
Configuration is stored in ~/.dash/config.json with structure:
|
|
13
|
+
{
|
|
14
|
+
"remote_url": "https://api.dash.ml",
|
|
15
|
+
"api_key": "token",
|
|
16
|
+
"default_batch_size": 100
|
|
17
|
+
}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".dash"
|
|
21
|
+
CONFIG_FILE = "config.json"
|
|
22
|
+
|
|
23
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
9
24
|
"""
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Configuration is stored in ~/.ml-dash/config.json with structure:
|
|
13
|
-
{
|
|
14
|
-
"remote_url": "https://api.dash.ml",
|
|
15
|
-
"api_key": "token",
|
|
16
|
-
"default_batch_size": 100
|
|
17
|
-
}
|
|
18
|
-
"""
|
|
25
|
+
Initialize config manager.
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
self.config_path
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"""Load config from file."""
|
|
36
|
-
if self.config_path.exists():
|
|
37
|
-
try:
|
|
38
|
-
with open(self.config_path, "r") as f:
|
|
39
|
-
return json.load(f)
|
|
40
|
-
except (json.JSONDecodeError, IOError):
|
|
41
|
-
# If config is corrupted, return empty dict
|
|
42
|
-
return {}
|
|
27
|
+
Args:
|
|
28
|
+
config_dir: Config directory path (defaults to ~/.dash)
|
|
29
|
+
"""
|
|
30
|
+
self.config_dir = config_dir or self.DEFAULT_CONFIG_DIR
|
|
31
|
+
self.config_path = self.config_dir / self.CONFIG_FILE
|
|
32
|
+
self._data = self._load()
|
|
33
|
+
|
|
34
|
+
def _load(self) -> Dict[str, Any]:
|
|
35
|
+
"""Load config from file."""
|
|
36
|
+
if self.config_path.exists():
|
|
37
|
+
try:
|
|
38
|
+
with open(self.config_path, "r") as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
except (json.JSONDecodeError, IOError):
|
|
41
|
+
# If config is corrupted, return empty dict
|
|
43
42
|
return {}
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
def save(self):
|
|
46
|
+
"""Save config to file."""
|
|
47
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
with open(self.config_path, "w") as f:
|
|
49
|
+
json.dump(self._data, f, indent=2)
|
|
50
|
+
|
|
51
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
52
|
+
"""
|
|
53
|
+
Get config value.
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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)
|
|
55
|
+
Args:
|
|
56
|
+
key: Config key
|
|
57
|
+
default: Default value if key not found
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Config value or default
|
|
61
|
+
"""
|
|
62
|
+
return self._data.get(key, default)
|
|
63
|
+
|
|
64
|
+
def set(self, key: str, value: Any):
|
|
65
|
+
"""
|
|
66
|
+
Set config value and save.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
key: Config key
|
|
70
|
+
value: Config value
|
|
71
|
+
"""
|
|
72
|
+
self._data[key] = value
|
|
73
|
+
self.save()
|
|
74
|
+
|
|
75
|
+
def delete(self, key: str):
|
|
76
|
+
"""
|
|
77
|
+
Delete config key and save.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
key: Config key to delete
|
|
81
|
+
"""
|
|
82
|
+
if key in self._data:
|
|
83
|
+
del self._data[key]
|
|
84
|
+
self.save()
|
|
85
|
+
|
|
86
|
+
def clear(self):
|
|
87
|
+
"""Clear all config and save."""
|
|
88
|
+
self._data = {}
|
|
89
|
+
self.save()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def remote_url(self) -> Optional[str]:
|
|
93
|
+
"""Get default remote URL."""
|
|
94
|
+
return self.get("remote_url", "https://api.dash.ml")
|
|
95
|
+
|
|
96
|
+
@remote_url.setter
|
|
97
|
+
def remote_url(self, url: str):
|
|
98
|
+
"""Set default remote URL."""
|
|
99
|
+
self.set("remote_url", url)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def api_key(self) -> Optional[str]:
|
|
103
|
+
"""Get default API key."""
|
|
104
|
+
return self.get("api_key")
|
|
105
|
+
|
|
106
|
+
@api_key.setter
|
|
107
|
+
def api_key(self, key: str):
|
|
108
|
+
"""Set default API key."""
|
|
109
|
+
self.set("api_key", key)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def batch_size(self) -> int:
|
|
113
|
+
"""Get default batch size for uploads."""
|
|
114
|
+
return self.get("default_batch_size", 100)
|
|
115
|
+
|
|
116
|
+
@batch_size.setter
|
|
117
|
+
def batch_size(self, size: int):
|
|
118
|
+
"""Set default batch size."""
|
|
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
130
|
|
|
131
131
|
|
|
132
132
|
# Global config instance
|