ml-dash 0.6.5__py3-none-any.whl → 0.6.6__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 +1 -2
- ml_dash/auto_start.py +1 -4
- ml_dash/cli.py +7 -1
- ml_dash/cli_commands/create.py +145 -0
- ml_dash/cli_commands/upload.py +17 -4
- ml_dash/client.py +63 -5
- ml_dash/experiment.py +231 -357
- ml_dash/run.py +92 -3
- ml_dash/storage.py +0 -2
- {ml_dash-0.6.5.dist-info → ml_dash-0.6.6.dist-info}/METADATA +1 -1
- {ml_dash-0.6.5.dist-info → ml_dash-0.6.6.dist-info}/RECORD +13 -12
- {ml_dash-0.6.5.dist-info → ml_dash-0.6.6.dist-info}/WHEEL +0 -0
- {ml_dash-0.6.5.dist-info → ml_dash-0.6.6.dist-info}/entry_points.txt +0 -0
ml_dash/__init__.py
CHANGED
|
@@ -37,7 +37,7 @@ Usage:
|
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
39
|
from .client import RemoteClient
|
|
40
|
-
from .experiment import Experiment, OperationMode,
|
|
40
|
+
from .experiment import Experiment, OperationMode, ml_dash_experiment
|
|
41
41
|
from .log import LogBuilder, LogLevel
|
|
42
42
|
from .params import ParametersBuilder
|
|
43
43
|
from .run import RUN
|
|
@@ -49,7 +49,6 @@ __all__ = [
|
|
|
49
49
|
"Experiment",
|
|
50
50
|
"ml_dash_experiment",
|
|
51
51
|
"OperationMode",
|
|
52
|
-
"RunManager",
|
|
53
52
|
"RemoteClient",
|
|
54
53
|
"LocalStorage",
|
|
55
54
|
"LogLevel",
|
ml_dash/auto_start.py
CHANGED
|
@@ -43,10 +43,7 @@ _user = get_jwt_user()
|
|
|
43
43
|
_username = _user["username"] if _user else getpass.getuser()
|
|
44
44
|
_now = datetime.now()
|
|
45
45
|
|
|
46
|
-
dxp = Experiment(
|
|
47
|
-
prefix=f"{_username}/scratch/{_now:%Y-%m-%d/%H%M%S}",
|
|
48
|
-
dash_url="https://api.dash.ml",
|
|
49
|
-
)
|
|
46
|
+
dxp = Experiment()
|
|
50
47
|
|
|
51
48
|
|
|
52
49
|
# Register cleanup handler to complete experiment on Python exit (if still open)
|
ml_dash/cli.py
CHANGED
|
@@ -25,7 +25,7 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
# Import and add command parsers
|
|
28
|
-
from .cli_commands import upload, download, list as list_cmd, login, logout, profile, api
|
|
28
|
+
from .cli_commands import upload, download, list as list_cmd, login, logout, profile, api, create
|
|
29
29
|
|
|
30
30
|
# Authentication commands
|
|
31
31
|
login.add_parser(subparsers)
|
|
@@ -35,6 +35,9 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
35
35
|
# API commands
|
|
36
36
|
api.add_parser(subparsers)
|
|
37
37
|
|
|
38
|
+
# Project commands
|
|
39
|
+
create.add_parser(subparsers)
|
|
40
|
+
|
|
38
41
|
# Data commands
|
|
39
42
|
upload.add_parser(subparsers)
|
|
40
43
|
download.add_parser(subparsers)
|
|
@@ -71,6 +74,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
71
74
|
elif args.command == "profile":
|
|
72
75
|
from .cli_commands import profile
|
|
73
76
|
return profile.cmd_profile(args)
|
|
77
|
+
elif args.command == "create":
|
|
78
|
+
from .cli_commands import create
|
|
79
|
+
return create.cmd_create(args)
|
|
74
80
|
elif args.command == "upload":
|
|
75
81
|
from .cli_commands import upload
|
|
76
82
|
return upload.cmd_upload(args)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Create command for ml-dash CLI - create projects."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from ml_dash.client import RemoteClient
|
|
9
|
+
from ml_dash.config import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(subparsers):
|
|
13
|
+
"""Add create command parser."""
|
|
14
|
+
parser = subparsers.add_parser(
|
|
15
|
+
"create",
|
|
16
|
+
help="Create a new project",
|
|
17
|
+
description="""Create a new project in ml-dash.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
# Create a project in current user's namespace
|
|
21
|
+
ml-dash create -p new-project
|
|
22
|
+
|
|
23
|
+
# Create a project in a specific namespace
|
|
24
|
+
ml-dash create -p geyang/new-project
|
|
25
|
+
|
|
26
|
+
# Create with description
|
|
27
|
+
ml-dash create -p geyang/tutorials -d "ML tutorials and examples"
|
|
28
|
+
""",
|
|
29
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-p", "--prefix",
|
|
33
|
+
type=str,
|
|
34
|
+
required=True,
|
|
35
|
+
help="Project name or namespace/project",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-d", "--description",
|
|
39
|
+
type=str,
|
|
40
|
+
help="Project description (optional)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--dash-url",
|
|
44
|
+
type=str,
|
|
45
|
+
help="ML-Dash server URL (default: https://api.dash.ml)",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_create(args) -> int:
|
|
50
|
+
"""Execute create command."""
|
|
51
|
+
console = Console()
|
|
52
|
+
|
|
53
|
+
# Get remote URL
|
|
54
|
+
remote_url = args.dash_url or config.remote_url or "https://api.dash.ml"
|
|
55
|
+
|
|
56
|
+
# Parse the prefix
|
|
57
|
+
prefix = args.prefix.strip("/")
|
|
58
|
+
parts = prefix.split("/")
|
|
59
|
+
|
|
60
|
+
if len(parts) > 2:
|
|
61
|
+
console.print(
|
|
62
|
+
f"[red]Error:[/red] Prefix can have at most 2 parts (namespace/project).\n"
|
|
63
|
+
f"Got: {args.prefix}\n\n"
|
|
64
|
+
f"Examples:\n"
|
|
65
|
+
f" ml-dash create -p new-project\n"
|
|
66
|
+
f" ml-dash create -p geyang/new-project"
|
|
67
|
+
)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
if len(parts) == 1:
|
|
71
|
+
# Format: project (use current user's namespace)
|
|
72
|
+
namespace = None
|
|
73
|
+
project_name = parts[0]
|
|
74
|
+
else:
|
|
75
|
+
# Format: namespace/project
|
|
76
|
+
namespace = parts[0]
|
|
77
|
+
project_name = parts[1]
|
|
78
|
+
|
|
79
|
+
return _create_project(
|
|
80
|
+
namespace=namespace,
|
|
81
|
+
project_name=project_name,
|
|
82
|
+
description=args.description,
|
|
83
|
+
dash_url=remote_url,
|
|
84
|
+
console=console,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _create_project(
|
|
89
|
+
namespace: Optional[str],
|
|
90
|
+
project_name: str,
|
|
91
|
+
description: Optional[str],
|
|
92
|
+
dash_url: str,
|
|
93
|
+
console: Console,
|
|
94
|
+
) -> int:
|
|
95
|
+
"""Create a new project."""
|
|
96
|
+
try:
|
|
97
|
+
# Initialize client (namespace will be auto-fetched from server if not provided)
|
|
98
|
+
client = RemoteClient(base_url=dash_url, namespace=namespace)
|
|
99
|
+
|
|
100
|
+
# Get namespace (triggers server query if not set)
|
|
101
|
+
namespace = client.namespace
|
|
102
|
+
|
|
103
|
+
if not namespace:
|
|
104
|
+
console.print("[red]Error:[/red] Could not determine namespace. Please login first.")
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
console.print(f"[dim]Creating project '{project_name}' in namespace '{namespace}'[/dim]")
|
|
108
|
+
|
|
109
|
+
# Create project using unified node API
|
|
110
|
+
response = client._client.post(
|
|
111
|
+
f"/namespaces/{namespace}/nodes",
|
|
112
|
+
json={
|
|
113
|
+
"type": "PROJECT",
|
|
114
|
+
"name": project_name,
|
|
115
|
+
"slug": project_name,
|
|
116
|
+
"description": description or "",
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
result = response.json()
|
|
121
|
+
|
|
122
|
+
# Extract project info
|
|
123
|
+
project = result.get("project", {})
|
|
124
|
+
project_id = project.get("id")
|
|
125
|
+
project_slug = project.get("slug")
|
|
126
|
+
|
|
127
|
+
# Success message
|
|
128
|
+
console.print(f"[green]✓[/green] Project created successfully!")
|
|
129
|
+
console.print(f" Name: [bold]{project_slug}[/bold]")
|
|
130
|
+
console.print(f" Namespace: [bold]{namespace}[/bold]")
|
|
131
|
+
console.print(f" ID: {project_id}")
|
|
132
|
+
if description:
|
|
133
|
+
console.print(f" Description: {description}")
|
|
134
|
+
console.print(f"\n View at: https://dash.ml/@{namespace}/{project_slug}")
|
|
135
|
+
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
# Check if it's a 409 conflict (project already exists)
|
|
140
|
+
if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 409:
|
|
141
|
+
console.print(f"[yellow]⚠[/yellow] Project '[bold]{project_name}[/bold]' already exists in namespace '[bold]{namespace}[/bold]'")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
console.print(f"[red]Error creating project:[/red] {e}")
|
|
145
|
+
return 1
|
ml_dash/cli_commands/upload.py
CHANGED
|
@@ -265,7 +265,11 @@ def discover_experiments(
|
|
|
265
265
|
|
|
266
266
|
Args:
|
|
267
267
|
local_path: Root path of local storage
|
|
268
|
-
project_filter:
|
|
268
|
+
project_filter: Either a simple project name (e.g., "proj1") or a glob
|
|
269
|
+
pattern for the full path (e.g., "tom/*/exp*"). If the
|
|
270
|
+
filter contains '/', '*', or '?', it's treated as a glob
|
|
271
|
+
pattern matched against the full relative path. Otherwise,
|
|
272
|
+
it's matched exactly against the project name.
|
|
269
273
|
experiment_filter: Only discover this experiment (requires project_filter)
|
|
270
274
|
|
|
271
275
|
Returns:
|
|
@@ -319,9 +323,18 @@ def discover_experiments(
|
|
|
319
323
|
|
|
320
324
|
# Apply filters with glob pattern support
|
|
321
325
|
if project_filter:
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
326
|
+
# Check if project_filter is a glob pattern or simple project name
|
|
327
|
+
is_glob_pattern = any(c in project_filter for c in ['*', '?', '/'])
|
|
328
|
+
|
|
329
|
+
if is_glob_pattern:
|
|
330
|
+
# Treat as glob pattern - match against full relative path
|
|
331
|
+
if not fnmatch.fnmatch(full_relative_path, project_filter):
|
|
332
|
+
continue
|
|
333
|
+
else:
|
|
334
|
+
# Treat as simple project name - match against parsed project
|
|
335
|
+
if project_name != project_filter:
|
|
336
|
+
continue
|
|
337
|
+
|
|
325
338
|
if experiment_filter and exp_name != experiment_filter:
|
|
326
339
|
continue
|
|
327
340
|
|
ml_dash/client.py
CHANGED
|
@@ -9,18 +9,19 @@ import httpx
|
|
|
9
9
|
class RemoteClient:
|
|
10
10
|
"""Client for communicating with ML-Dash server."""
|
|
11
11
|
|
|
12
|
-
def __init__(self, base_url: str, namespace: str, api_key: Optional[str] = None):
|
|
12
|
+
def __init__(self, base_url: str, namespace: Optional[str] = None, 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
|
-
namespace: Namespace slug (e.g., "my-namespace")
|
|
18
|
+
namespace: Namespace slug (e.g., "my-namespace"). If not provided, will be queried from server.
|
|
19
19
|
api_key: JWT token for authentication (optional - auto-loads from storage if not provided)
|
|
20
20
|
|
|
21
21
|
Note:
|
|
22
22
|
If no api_key is provided, token will be loaded from storage on first API call.
|
|
23
23
|
If still not found, AuthenticationError will be raised at that time.
|
|
24
|
+
If no namespace is provided, it will be fetched from the server on first API call.
|
|
24
25
|
"""
|
|
25
26
|
# Store original base URL for GraphQL (no /api prefix)
|
|
26
27
|
self.graphql_base_url = base_url.rstrip("/")
|
|
@@ -28,9 +29,6 @@ class RemoteClient:
|
|
|
28
29
|
# Add /api prefix to base URL for REST API calls
|
|
29
30
|
self.base_url = base_url.rstrip("/") + "/api"
|
|
30
31
|
|
|
31
|
-
# Store namespace
|
|
32
|
-
self.namespace = namespace
|
|
33
|
-
|
|
34
32
|
# If no api_key provided, try to load from storage
|
|
35
33
|
if not api_key:
|
|
36
34
|
from .auth.token_storage import get_token_storage
|
|
@@ -39,10 +37,70 @@ class RemoteClient:
|
|
|
39
37
|
api_key = storage.load("ml-dash-token")
|
|
40
38
|
|
|
41
39
|
self.api_key = api_key
|
|
40
|
+
|
|
41
|
+
# Store namespace (can be None, will be fetched on first API call if needed)
|
|
42
|
+
self._namespace = namespace
|
|
43
|
+
self._namespace_fetched = False
|
|
44
|
+
|
|
42
45
|
self._rest_client = None
|
|
43
46
|
self._gql_client = None
|
|
44
47
|
self._id_cache: Dict[str, str] = {} # Cache for slug -> ID mappings
|
|
45
48
|
|
|
49
|
+
@property
|
|
50
|
+
def namespace(self) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Get namespace, fetching from server if not already set.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Namespace slug
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
AuthenticationError: If not authenticated
|
|
59
|
+
ValueError: If namespace cannot be determined
|
|
60
|
+
"""
|
|
61
|
+
if self._namespace:
|
|
62
|
+
return self._namespace
|
|
63
|
+
|
|
64
|
+
if not self._namespace_fetched:
|
|
65
|
+
# Fetch namespace from server
|
|
66
|
+
self._namespace = self._fetch_namespace_from_server()
|
|
67
|
+
self._namespace_fetched = True
|
|
68
|
+
|
|
69
|
+
if not self._namespace:
|
|
70
|
+
raise ValueError("Could not determine namespace. Please provide --namespace explicitly.")
|
|
71
|
+
|
|
72
|
+
return self._namespace
|
|
73
|
+
|
|
74
|
+
@namespace.setter
|
|
75
|
+
def namespace(self, value: str):
|
|
76
|
+
"""Set namespace."""
|
|
77
|
+
self._namespace = value
|
|
78
|
+
self._namespace_fetched = True
|
|
79
|
+
|
|
80
|
+
def _fetch_namespace_from_server(self) -> Optional[str]:
|
|
81
|
+
"""
|
|
82
|
+
Fetch current user's namespace from server.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Namespace slug or None if cannot be determined
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
self._ensure_authenticated()
|
|
89
|
+
|
|
90
|
+
# Query server for current user's namespace
|
|
91
|
+
query = """
|
|
92
|
+
query GetMyNamespace {
|
|
93
|
+
me {
|
|
94
|
+
username
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
"""
|
|
98
|
+
result = self.graphql_query(query)
|
|
99
|
+
username = result.get("me", {}).get("username")
|
|
100
|
+
return username
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
46
104
|
def _ensure_authenticated(self):
|
|
47
105
|
"""Check if authenticated, raise error if not."""
|
|
48
106
|
if not self.api_key:
|