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/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
- projects = result.get("namespace", {}).get("projects", [])
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
- raise ValueError(f"Project '{project_slug}' not found in namespace '{self.namespace}'")
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
- "projectId": project_id,
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 (DEPRECATED - use parent_id for folder structure)
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
- return result.get("data", {})
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
  """