ml-dash 0.6.4__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 +378 -18
- ml_dash/experiment.py +231 -357
- ml_dash/run.py +92 -3
- ml_dash/storage.py +0 -2
- {ml_dash-0.6.4.dist-info → ml_dash-0.6.6.dist-info}/METADATA +1 -1
- {ml_dash-0.6.4.dist-info → ml_dash-0.6.6.dist-info}/RECORD +13 -12
- {ml_dash-0.6.4.dist-info → ml_dash-0.6.6.dist-info}/WHEEL +0 -0
- {ml_dash-0.6.4.dist-info → ml_dash-0.6.6.dist-info}/entry_points.txt +0 -0
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:
|
|
@@ -82,7 +140,7 @@ class RemoteClient:
|
|
|
82
140
|
)
|
|
83
141
|
return self._gql_client
|
|
84
142
|
|
|
85
|
-
def _get_project_id(self, project_slug: str) -> str:
|
|
143
|
+
def _get_project_id(self, project_slug: str) -> Optional[str]:
|
|
86
144
|
"""
|
|
87
145
|
Resolve project ID from slug using GraphQL.
|
|
88
146
|
|
|
@@ -90,10 +148,8 @@ class RemoteClient:
|
|
|
90
148
|
project_slug: Project slug
|
|
91
149
|
|
|
92
150
|
Returns:
|
|
93
|
-
Project ID (Snowflake ID)
|
|
94
|
-
|
|
95
|
-
Raises:
|
|
96
|
-
ValueError: If project not found
|
|
151
|
+
Project ID (Snowflake ID) if found, None if not found
|
|
152
|
+
When None is returned, the server will auto-create the project
|
|
97
153
|
"""
|
|
98
154
|
cache_key = f"project:{self.namespace}:{project_slug}"
|
|
99
155
|
if cache_key in self._id_cache:
|
|
@@ -113,14 +169,19 @@ class RemoteClient:
|
|
|
113
169
|
"namespace": self.namespace
|
|
114
170
|
})
|
|
115
171
|
|
|
116
|
-
|
|
172
|
+
namespace_data = result.get("namespace")
|
|
173
|
+
if namespace_data is None:
|
|
174
|
+
raise ValueError(f"Namespace '{self.namespace}' not found. Please check the namespace exists on the server.")
|
|
175
|
+
|
|
176
|
+
projects = namespace_data.get("projects", [])
|
|
117
177
|
for project in projects:
|
|
118
178
|
if project["slug"] == project_slug:
|
|
119
179
|
project_id = project["id"]
|
|
120
180
|
self._id_cache[cache_key] = project_id
|
|
121
181
|
return project_id
|
|
122
182
|
|
|
123
|
-
|
|
183
|
+
# Project not found - return None to let server auto-create it
|
|
184
|
+
return None
|
|
124
185
|
|
|
125
186
|
def _get_experiment_node_id(self, experiment_id: str) -> str:
|
|
126
187
|
"""
|
|
@@ -182,21 +243,85 @@ class RemoteClient:
|
|
|
182
243
|
|
|
183
244
|
Returns:
|
|
184
245
|
Response dict with experiment, node, and project data
|
|
246
|
+
Note: Project will be auto-created if it doesn't exist
|
|
185
247
|
|
|
186
248
|
Raises:
|
|
187
249
|
httpx.HTTPStatusError: If request fails
|
|
188
|
-
ValueError: If project not found
|
|
189
250
|
"""
|
|
190
|
-
# Resolve project ID from slug
|
|
251
|
+
# Resolve project ID from slug (returns None if not found)
|
|
191
252
|
project_id = self._get_project_id(project)
|
|
192
253
|
|
|
254
|
+
# Parse prefix to create folder hierarchy for experiment
|
|
255
|
+
# prefix format: "namespace/project/folder1/folder2/experiment_name"
|
|
256
|
+
# We need to create folders: folder1 -> folder2 and place experiment under folder2
|
|
257
|
+
parent_id = "ROOT"
|
|
258
|
+
|
|
259
|
+
if prefix:
|
|
260
|
+
# Parse prefix to extract folder path
|
|
261
|
+
parts = prefix.strip('/').split('/')
|
|
262
|
+
# parts: [namespace, project, folder1, folder2, ..., experiment_name]
|
|
263
|
+
|
|
264
|
+
if len(parts) >= 3:
|
|
265
|
+
# We have at least namespace/project/something
|
|
266
|
+
# Extract folder parts (everything between project and experiment name)
|
|
267
|
+
# Skip namespace (parts[0]) and project (parts[1])
|
|
268
|
+
# Skip experiment name (parts[-1])
|
|
269
|
+
folder_parts = parts[2:-1] if len(parts) > 3 else []
|
|
270
|
+
|
|
271
|
+
if folder_parts:
|
|
272
|
+
# Ensure we have a project_id for folder creation
|
|
273
|
+
if not project_id:
|
|
274
|
+
# Create the project first since we need its ID for folders
|
|
275
|
+
project_response = self._client.post(
|
|
276
|
+
f"/namespaces/{self.namespace}/nodes",
|
|
277
|
+
json={
|
|
278
|
+
"type": "PROJECT",
|
|
279
|
+
"name": project,
|
|
280
|
+
"slug": project,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
project_response.raise_for_status()
|
|
284
|
+
project_data = project_response.json()
|
|
285
|
+
project_id = project_data.get("project", {}).get("id")
|
|
286
|
+
|
|
287
|
+
if project_id:
|
|
288
|
+
# Create folder hierarchy
|
|
289
|
+
current_parent_id = "ROOT"
|
|
290
|
+
for folder_name in folder_parts:
|
|
291
|
+
if not folder_name:
|
|
292
|
+
continue
|
|
293
|
+
# Create folder (server handles upsert)
|
|
294
|
+
# NOTE: Do NOT pass experimentId for project-level folders
|
|
295
|
+
folder_response = self._client.post(
|
|
296
|
+
f"/namespaces/{self.namespace}/nodes",
|
|
297
|
+
json={
|
|
298
|
+
"type": "FOLDER",
|
|
299
|
+
"projectId": project_id,
|
|
300
|
+
"parentId": current_parent_id,
|
|
301
|
+
"name": folder_name
|
|
302
|
+
# experimentId intentionally omitted - these are project-level folders
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
folder_response.raise_for_status()
|
|
306
|
+
folder_data = folder_response.json()
|
|
307
|
+
current_parent_id = folder_data.get("node", {}).get("id")
|
|
308
|
+
|
|
309
|
+
# Update parent_id for experiment
|
|
310
|
+
parent_id = current_parent_id
|
|
311
|
+
|
|
193
312
|
# Build payload for unified node API
|
|
194
313
|
payload = {
|
|
195
314
|
"type": "EXPERIMENT",
|
|
196
315
|
"name": name,
|
|
197
|
-
"
|
|
316
|
+
"parentId": parent_id,
|
|
198
317
|
}
|
|
199
318
|
|
|
319
|
+
# Send projectId if available, otherwise projectSlug (server will auto-create)
|
|
320
|
+
if project_id:
|
|
321
|
+
payload["projectId"] = project_id
|
|
322
|
+
else:
|
|
323
|
+
payload["projectSlug"] = project
|
|
324
|
+
|
|
200
325
|
if description is not None:
|
|
201
326
|
payload["description"] = description
|
|
202
327
|
if tags is not None:
|
|
@@ -369,7 +494,10 @@ class RemoteClient:
|
|
|
369
494
|
Args:
|
|
370
495
|
experiment_id: Experiment ID (Snowflake ID)
|
|
371
496
|
file_path: Local file path
|
|
372
|
-
prefix: Logical path prefix
|
|
497
|
+
prefix: Logical path prefix for folder structure (e.g., "models/checkpoints")
|
|
498
|
+
Will create nested folders automatically. May include namespace/project
|
|
499
|
+
parts which will be stripped automatically (e.g., "ns/proj/folder1/folder2"
|
|
500
|
+
will create folders: folder1 -> folder2)
|
|
373
501
|
filename: Original filename
|
|
374
502
|
description: Optional description
|
|
375
503
|
tags: Optional tags
|
|
@@ -378,7 +506,8 @@ class RemoteClient:
|
|
|
378
506
|
content_type: MIME type
|
|
379
507
|
size_bytes: File size in bytes
|
|
380
508
|
project_id: Project ID (optional - will be resolved from experiment if not provided)
|
|
381
|
-
parent_id: Parent node ID (folder) or "ROOT" for root level
|
|
509
|
+
parent_id: Parent node ID (folder) or "ROOT" for root level.
|
|
510
|
+
If prefix is provided, folders will be created under this parent.
|
|
382
511
|
|
|
383
512
|
Returns:
|
|
384
513
|
Response dict with node and physicalFile data
|
|
@@ -402,6 +531,236 @@ class RemoteClient:
|
|
|
402
531
|
if not project_id:
|
|
403
532
|
raise ValueError(f"Could not resolve project ID for experiment {experiment_id}")
|
|
404
533
|
|
|
534
|
+
# Resolve experiment node ID (files should be children of the experiment node, not ROOT)
|
|
535
|
+
# Check cache first, otherwise query
|
|
536
|
+
experiment_node_id = self._id_cache.get(f"exp_node:{experiment_id}")
|
|
537
|
+
if not experiment_node_id:
|
|
538
|
+
# Query to get the experiment node ID
|
|
539
|
+
query = """
|
|
540
|
+
query GetExperimentNode($experimentId: ID!) {
|
|
541
|
+
experimentById(id: $experimentId) {
|
|
542
|
+
id
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
"""
|
|
546
|
+
# Note: experimentById returns the Experiment record, not the Node
|
|
547
|
+
# We need to find the Node with type=EXPERIMENT and experimentId=experiment_id
|
|
548
|
+
# Use the project nodes query instead
|
|
549
|
+
query = """
|
|
550
|
+
query GetExperimentNode($projectId: ID!, $experimentId: ID!) {
|
|
551
|
+
project(id: $projectId) {
|
|
552
|
+
nodes(parentId: null, maxDepth: 10) {
|
|
553
|
+
id
|
|
554
|
+
type
|
|
555
|
+
experimentId
|
|
556
|
+
children {
|
|
557
|
+
id
|
|
558
|
+
type
|
|
559
|
+
experimentId
|
|
560
|
+
children {
|
|
561
|
+
id
|
|
562
|
+
type
|
|
563
|
+
experimentId
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
"""
|
|
570
|
+
result = self.graphql_query(query, {"projectId": project_id, "experimentId": experiment_id})
|
|
571
|
+
|
|
572
|
+
# Find the experiment node
|
|
573
|
+
def find_experiment_node(nodes, exp_id):
|
|
574
|
+
for node in nodes:
|
|
575
|
+
if node.get("type") == "EXPERIMENT" and node.get("experimentId") == exp_id:
|
|
576
|
+
return node.get("id")
|
|
577
|
+
if node.get("children"):
|
|
578
|
+
found = find_experiment_node(node["children"], exp_id)
|
|
579
|
+
if found:
|
|
580
|
+
return found
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
project_nodes = result.get("project", {}).get("nodes", [])
|
|
584
|
+
experiment_node_id = find_experiment_node(project_nodes, experiment_id)
|
|
585
|
+
|
|
586
|
+
if experiment_node_id:
|
|
587
|
+
# Cache it for future uploads
|
|
588
|
+
self._id_cache[f"exp_node:{experiment_id}"] = experiment_node_id
|
|
589
|
+
else:
|
|
590
|
+
# Fallback to ROOT if we can't find the experiment node
|
|
591
|
+
# This might happen for old experiments or legacy data
|
|
592
|
+
experiment_node_id = "ROOT"
|
|
593
|
+
|
|
594
|
+
# Get experiment node path to strip from prefix
|
|
595
|
+
# When we use experiment_node_id as parent, we need to strip the experiment's
|
|
596
|
+
# folder path from the prefix to avoid creating duplicate folders
|
|
597
|
+
# We'll cache this in the id_cache to avoid repeated queries
|
|
598
|
+
cache_key = f"exp_folder_path:{experiment_id}"
|
|
599
|
+
experiment_folder_path = self._id_cache.get(cache_key)
|
|
600
|
+
|
|
601
|
+
if experiment_folder_path is None and experiment_node_id != "ROOT":
|
|
602
|
+
# Query experiment to get its project info for the GraphQL query
|
|
603
|
+
exp_query = """
|
|
604
|
+
query GetExpInfo($experimentId: ID!) {
|
|
605
|
+
experimentById(id: $experimentId) {
|
|
606
|
+
project {
|
|
607
|
+
slug
|
|
608
|
+
namespace {
|
|
609
|
+
slug
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
"""
|
|
615
|
+
exp_result = self.graphql_query(exp_query, {"experimentId": experiment_id})
|
|
616
|
+
project_slug = exp_result.get("experimentById", {}).get("project", {}).get("slug")
|
|
617
|
+
namespace_slug = exp_result.get("experimentById", {}).get("project", {}).get("namespace", {}).get("slug")
|
|
618
|
+
|
|
619
|
+
if project_slug and namespace_slug:
|
|
620
|
+
# Query to get the experiment node's path
|
|
621
|
+
# This includes all ancestor folders up to the experiment
|
|
622
|
+
query = """
|
|
623
|
+
query GetExperimentPath($namespaceSlug: String!, $projectSlug: String!) {
|
|
624
|
+
project(namespaceSlug: $namespaceSlug, projectSlug: $projectSlug) {
|
|
625
|
+
nodes(parentId: null, maxDepth: 10) {
|
|
626
|
+
id
|
|
627
|
+
name
|
|
628
|
+
type
|
|
629
|
+
experimentId
|
|
630
|
+
parentId
|
|
631
|
+
children {
|
|
632
|
+
id
|
|
633
|
+
name
|
|
634
|
+
type
|
|
635
|
+
experimentId
|
|
636
|
+
parentId
|
|
637
|
+
children {
|
|
638
|
+
id
|
|
639
|
+
name
|
|
640
|
+
type
|
|
641
|
+
experimentId
|
|
642
|
+
parentId
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
"""
|
|
649
|
+
result = self.graphql_query(query, {"namespaceSlug": namespace_slug, "projectSlug": project_slug})
|
|
650
|
+
|
|
651
|
+
# Build path to experiment node
|
|
652
|
+
def find_node_path(nodes, target_id, current_path=None):
|
|
653
|
+
if current_path is None:
|
|
654
|
+
current_path = []
|
|
655
|
+
for node in nodes:
|
|
656
|
+
new_path = current_path + [node.get("name")]
|
|
657
|
+
if node.get("id") == target_id:
|
|
658
|
+
return new_path
|
|
659
|
+
if node.get("children"):
|
|
660
|
+
found = find_node_path(node["children"], target_id, new_path)
|
|
661
|
+
if found:
|
|
662
|
+
return found
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
project_nodes = result.get("project", {}).get("nodes", [])
|
|
666
|
+
path_parts = find_node_path(project_nodes, experiment_node_id)
|
|
667
|
+
if path_parts:
|
|
668
|
+
# IMPORTANT: Don't include the experiment node's name itself
|
|
669
|
+
# We want the path TO the experiment's parent folder, not the experiment
|
|
670
|
+
# E.g., if path is ["examples", "exp-name"], we want "examples"
|
|
671
|
+
if len(path_parts) > 1:
|
|
672
|
+
experiment_folder_path = "/".join(path_parts[:-1])
|
|
673
|
+
else:
|
|
674
|
+
# Experiment is at root level, no parent folders
|
|
675
|
+
experiment_folder_path = ""
|
|
676
|
+
# Cache it
|
|
677
|
+
self._id_cache[cache_key] = experiment_folder_path
|
|
678
|
+
else:
|
|
679
|
+
# Couldn't find path, set empty string to avoid re-querying
|
|
680
|
+
experiment_folder_path = ""
|
|
681
|
+
self._id_cache[cache_key] = experiment_folder_path
|
|
682
|
+
|
|
683
|
+
# Use experiment node ID as the parent for file uploads
|
|
684
|
+
# Files and folders should be children of the experiment node
|
|
685
|
+
if parent_id == "ROOT" and experiment_node_id != "ROOT":
|
|
686
|
+
parent_id = experiment_node_id
|
|
687
|
+
|
|
688
|
+
# Parse prefix to create folder hierarchy
|
|
689
|
+
# prefix like "models/checkpoints" should create folders: models -> checkpoints
|
|
690
|
+
# NOTE: The prefix may contain namespace/project parts (e.g., "ns/proj/folder1/folder2")
|
|
691
|
+
# We need to strip the namespace and project parts since we're already in an experiment context
|
|
692
|
+
if prefix and prefix != '/' and prefix.strip():
|
|
693
|
+
# Clean and normalize prefix
|
|
694
|
+
prefix = prefix.strip('/')
|
|
695
|
+
|
|
696
|
+
# Try to detect and strip namespace/project from prefix
|
|
697
|
+
# Common patterns: "namespace/project/folders..." or just "folders..."
|
|
698
|
+
# Since we're in experiment context, we already know the namespace and project
|
|
699
|
+
# Check if prefix starts with namespace
|
|
700
|
+
if prefix.startswith(self.namespace + '/'):
|
|
701
|
+
# Strip namespace
|
|
702
|
+
prefix = prefix[len(self.namespace) + 1:]
|
|
703
|
+
|
|
704
|
+
# Now check if it starts with project slug/name
|
|
705
|
+
# We need to query the experiment to get the project info
|
|
706
|
+
query = """
|
|
707
|
+
query GetExperimentProject($experimentId: ID!) {
|
|
708
|
+
experimentById(id: $experimentId) {
|
|
709
|
+
project {
|
|
710
|
+
slug
|
|
711
|
+
name
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
"""
|
|
716
|
+
exp_result = self.graphql_query(query, {"experimentId": experiment_id})
|
|
717
|
+
project_info = exp_result.get("experimentById", {}).get("project", {})
|
|
718
|
+
project_slug = project_info.get("slug", "")
|
|
719
|
+
project_name = project_info.get("name", "")
|
|
720
|
+
|
|
721
|
+
# Try to strip project slug or name
|
|
722
|
+
if project_slug and prefix.startswith(project_slug + '/'):
|
|
723
|
+
prefix = prefix[len(project_slug) + 1:]
|
|
724
|
+
elif project_name and prefix.startswith(project_name + '/'):
|
|
725
|
+
prefix = prefix[len(project_name) + 1:]
|
|
726
|
+
|
|
727
|
+
# Strip experiment folder path from prefix since we're using experiment node as parent
|
|
728
|
+
# For example: if prefix is "examples/exp1/models" and experiment is at "examples/exp1",
|
|
729
|
+
# strip "examples/exp1/" to get "models"
|
|
730
|
+
if experiment_folder_path and prefix.startswith(experiment_folder_path + '/'):
|
|
731
|
+
prefix = prefix[len(experiment_folder_path) + 1:]
|
|
732
|
+
elif experiment_folder_path and prefix == experiment_folder_path:
|
|
733
|
+
# Prefix is exactly the experiment path, no subfolders
|
|
734
|
+
prefix = ""
|
|
735
|
+
|
|
736
|
+
if prefix:
|
|
737
|
+
folder_parts = prefix.split('/')
|
|
738
|
+
current_parent_id = parent_id
|
|
739
|
+
|
|
740
|
+
# Create or find each folder in the hierarchy
|
|
741
|
+
# Server handles upsert - will return existing folder if it exists
|
|
742
|
+
for folder_name in folder_parts:
|
|
743
|
+
if not folder_name: # Skip empty parts
|
|
744
|
+
continue
|
|
745
|
+
|
|
746
|
+
# Create folder (server will return existing if duplicate)
|
|
747
|
+
folder_response = self._client.post(
|
|
748
|
+
f"/namespaces/{self.namespace}/nodes",
|
|
749
|
+
json={
|
|
750
|
+
"type": "FOLDER",
|
|
751
|
+
"projectId": project_id,
|
|
752
|
+
"experimentId": experiment_id,
|
|
753
|
+
"parentId": current_parent_id,
|
|
754
|
+
"name": folder_name
|
|
755
|
+
}
|
|
756
|
+
)
|
|
757
|
+
folder_response.raise_for_status()
|
|
758
|
+
folder_data = folder_response.json()
|
|
759
|
+
current_parent_id = folder_data.get("node", {}).get("id")
|
|
760
|
+
|
|
761
|
+
# Update parent_id to the final folder in the hierarchy
|
|
762
|
+
parent_id = current_parent_id
|
|
763
|
+
|
|
405
764
|
# Prepare multipart form data
|
|
406
765
|
with open(file_path, "rb") as f:
|
|
407
766
|
file_content = f.read()
|
|
@@ -833,7 +1192,8 @@ class RemoteClient:
|
|
|
833
1192
|
if "errors" in result:
|
|
834
1193
|
raise Exception(f"GraphQL errors: {result['errors']}")
|
|
835
1194
|
|
|
836
|
-
|
|
1195
|
+
# Handle case where data is explicitly null in response
|
|
1196
|
+
return result.get("data") or {}
|
|
837
1197
|
|
|
838
1198
|
def list_projects_graphql(self) -> List[Dict[str, Any]]:
|
|
839
1199
|
"""
|