ml-dash 0.6.1__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/storage.py ADDED
@@ -0,0 +1,1127 @@
1
+ """
2
+ Local filesystem storage for ML-Dash.
3
+ """
4
+
5
+ from typing import Optional, Dict, Any, List
6
+ from pathlib import Path
7
+ import json
8
+ from datetime import datetime
9
+ import threading
10
+ import time
11
+ import fcntl
12
+ import sys
13
+ from contextlib import contextmanager
14
+
15
+
16
+ class LocalStorage:
17
+ """
18
+ Local filesystem storage backend.
19
+
20
+ Directory structure:
21
+ <root>/
22
+ <project>/
23
+ <experiment_name>/
24
+ experiment.json # Experiment metadata
25
+ logs/
26
+ logs.jsonl # Log entries
27
+ .log_sequence # Sequence counter
28
+ metrics/
29
+ <metric_name>.jsonl
30
+ files/
31
+ <uploaded_files>
32
+ parameters.json # Flattened parameters
33
+ """
34
+
35
+ def __init__(self, root_path: Path):
36
+ """
37
+ Initialize local storage.
38
+
39
+ Args:
40
+ root_path: Root directory for local storage
41
+ """
42
+ self.root_path = Path(root_path)
43
+ self.root_path.mkdir(parents=True, exist_ok=True)
44
+
45
+ @contextmanager
46
+ def _file_lock(self, lock_file: Path):
47
+ """
48
+ Context manager for file-based locking (works across processes and threads).
49
+
50
+ Args:
51
+ lock_file: Path to the lock file
52
+
53
+ Yields:
54
+ File handle with exclusive lock
55
+ """
56
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
57
+ lock_fd = None
58
+
59
+ try:
60
+ # Open lock file
61
+ lock_fd = open(lock_file, 'a')
62
+
63
+ # Acquire exclusive lock (blocking)
64
+ # Use fcntl on Unix-like systems
65
+ if hasattr(fcntl, 'flock'):
66
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
67
+ elif hasattr(fcntl, 'lockf'):
68
+ fcntl.lockf(lock_fd.fileno(), fcntl.LOCK_EX)
69
+ else:
70
+ # Fallback for systems without fcntl (like Windows)
71
+ # Use simple file existence as lock (not perfect but better than nothing)
72
+ pass
73
+
74
+ yield lock_fd
75
+
76
+ finally:
77
+ # Release lock and close file
78
+ if lock_fd:
79
+ try:
80
+ if hasattr(fcntl, 'flock'):
81
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
82
+ elif hasattr(fcntl, 'lockf'):
83
+ fcntl.lockf(lock_fd.fileno(), fcntl.LOCK_UN)
84
+ except Exception:
85
+ pass
86
+ lock_fd.close()
87
+
88
+ def create_experiment(
89
+ self,
90
+ project: str,
91
+ name: str,
92
+ description: Optional[str] = None,
93
+ tags: Optional[List[str]] = None,
94
+ bindrs: Optional[List[str]] = None,
95
+ folder: Optional[str] = None,
96
+ metadata: Optional[Dict[str, Any]] = None,
97
+ ) -> Path:
98
+ """
99
+ Create a experiment directory structure.
100
+
101
+ Args:
102
+ project: Project name
103
+ name: Experiment name
104
+ description: Optional description
105
+ tags: Optional tags
106
+ bindrs: Optional bindrs
107
+ folder: Optional folder path (used for organization)
108
+ metadata: Optional metadata
109
+
110
+ Returns:
111
+ Path to experiment directory
112
+ """
113
+ # Determine base path - include folder in hierarchy if specified
114
+ if folder is not None:
115
+ # Strip leading / to make it relative, then use as base path
116
+ folder_path = folder.lstrip('/')
117
+ base_path = self.root_path / folder_path
118
+ else:
119
+ base_path = self.root_path
120
+
121
+ # Create project directory
122
+ project_dir = base_path / project
123
+ project_dir.mkdir(parents=True, exist_ok=True)
124
+
125
+ # Create experiment directory
126
+ experiment_dir = project_dir / name
127
+ experiment_dir.mkdir(parents=True, exist_ok=True)
128
+
129
+ # Create subdirectories
130
+ (experiment_dir / "logs").mkdir(exist_ok=True)
131
+ (experiment_dir / "metrics").mkdir(exist_ok=True)
132
+ (experiment_dir / "files").mkdir(exist_ok=True)
133
+
134
+ # Write experiment metadata
135
+ experiment_metadata = {
136
+ "name": name,
137
+ "project": project,
138
+ "description": description,
139
+ "tags": tags or [],
140
+ "bindrs": bindrs or [],
141
+ "folder": folder,
142
+ "metadata": metadata,
143
+ "created_at": datetime.utcnow().isoformat() + "Z",
144
+ "write_protected": False,
145
+ }
146
+
147
+ experiment_file = experiment_dir / "experiment.json"
148
+
149
+ # File-based lock for concurrent experiment creation/update
150
+ lock_file = experiment_dir / ".experiment.lock"
151
+ with self._file_lock(lock_file):
152
+ if not experiment_file.exists():
153
+ # Only create if doesn't exist (don't overwrite)
154
+ with open(experiment_file, "w") as f:
155
+ json.dump(experiment_metadata, f, indent=2)
156
+ else:
157
+ # Update existing experiment
158
+ try:
159
+ with open(experiment_file, "r") as f:
160
+ existing = json.load(f)
161
+ except (json.JSONDecodeError, IOError):
162
+ # File might be corrupted or empty, recreate it
163
+ with open(experiment_file, "w") as f:
164
+ json.dump(experiment_metadata, f, indent=2)
165
+ return experiment_dir
166
+
167
+ # Merge updates
168
+ if description is not None:
169
+ existing["description"] = description
170
+ if tags is not None:
171
+ existing["tags"] = tags
172
+ if bindrs is not None:
173
+ existing["bindrs"] = bindrs
174
+ if folder is not None:
175
+ existing["folder"] = folder
176
+ if metadata is not None:
177
+ existing["metadata"] = metadata
178
+ existing["updated_at"] = datetime.utcnow().isoformat() + "Z"
179
+ with open(experiment_file, "w") as f:
180
+ json.dump(existing, f, indent=2)
181
+
182
+ return experiment_dir
183
+
184
+ def flush(self):
185
+ """Flush any pending writes (no-op for now)."""
186
+ pass
187
+
188
+ def write_log(
189
+ self,
190
+ project: str,
191
+ experiment: str,
192
+ folder: Optional[str] = None,
193
+ message: str = "",
194
+ level: str = "info",
195
+ timestamp: str = "",
196
+ metadata: Optional[Dict[str, Any]] = None,
197
+ ):
198
+ """
199
+ Write a single log entry immediately to JSONL file.
200
+
201
+ Args:
202
+ project: Project name
203
+ experiment: Experiment name
204
+ folder: Optional folder path
205
+ message: Log message
206
+ level: Log level
207
+ timestamp: ISO timestamp string
208
+ metadata: Optional metadata
209
+ """
210
+ experiment_dir = self._get_experiment_dir(project, experiment, folder)
211
+ logs_dir = experiment_dir / "logs"
212
+ logs_file = logs_dir / "logs.jsonl"
213
+ seq_file = logs_dir / ".log_sequence"
214
+
215
+ # File-based lock for concurrent log writes (prevents sequence collision)
216
+ lock_file = logs_dir / ".log_sequence.lock"
217
+ with self._file_lock(lock_file):
218
+ # Read and increment sequence counter
219
+ sequence_number = 0
220
+ if seq_file.exists():
221
+ try:
222
+ sequence_number = int(seq_file.read_text().strip())
223
+ except (ValueError, IOError):
224
+ sequence_number = 0
225
+
226
+ log_entry = {
227
+ "sequenceNumber": sequence_number,
228
+ "timestamp": timestamp,
229
+ "level": level,
230
+ "message": message,
231
+ }
232
+
233
+ if metadata:
234
+ log_entry["metadata"] = metadata
235
+
236
+ # Write log immediately
237
+ with open(logs_file, "a") as f:
238
+ f.write(json.dumps(log_entry) + "\n")
239
+
240
+ # Update sequence counter
241
+ seq_file.write_text(str(sequence_number + 1))
242
+
243
+ def write_metric_data(
244
+ self,
245
+ project: str,
246
+ experiment: str,
247
+ metric_name: str,
248
+ data: Any,
249
+ ):
250
+ """
251
+ Write metric data point.
252
+
253
+ Args:
254
+ project: Project name
255
+ experiment: Experiment name
256
+ metric_name: Metric name
257
+ data: Data point
258
+ """
259
+ experiment_dir = self._get_experiment_dir(project, experiment)
260
+ metric_file = experiment_dir / "metrics" / f"{metric_name}.jsonl"
261
+
262
+ data_point = {
263
+ "timestamp": datetime.utcnow().isoformat() + "Z",
264
+ "data": data,
265
+ }
266
+
267
+ with open(metric_file, "a") as f:
268
+ f.write(json.dumps(data_point) + "\n")
269
+
270
+ def write_parameters(
271
+ self,
272
+ project: str,
273
+ experiment: str,
274
+ folder: Optional[str] = None,
275
+ data: Optional[Dict[str, Any]] = None,
276
+ ):
277
+ """
278
+ Write/merge parameters. Always merges with existing parameters.
279
+
280
+ File format:
281
+ {
282
+ "version": 2,
283
+ "data": {"model.lr": 0.001, "model.batch_size": 32},
284
+ "updatedAt": "2024-01-15T10:30:00Z"
285
+ }
286
+
287
+ Args:
288
+ project: Project name
289
+ experiment: Experiment name
290
+ folder: Optional folder path
291
+ data: Flattened parameter dict with dot notation (already flattened)
292
+ """
293
+ if data is None:
294
+ data = {}
295
+ experiment_dir = self._get_experiment_dir(project, experiment, folder)
296
+ params_file = experiment_dir / "parameters.json"
297
+
298
+ # File-based lock for concurrent parameter writes (prevents data loss and version conflicts)
299
+ lock_file = experiment_dir / ".parameters.lock"
300
+ with self._file_lock(lock_file):
301
+ # Read existing if present
302
+ if params_file.exists():
303
+ try:
304
+ with open(params_file, "r") as f:
305
+ existing_doc = json.load(f)
306
+ except (json.JSONDecodeError, IOError):
307
+ # Corrupted file, recreate
308
+ existing_doc = None
309
+
310
+ if existing_doc:
311
+ # Merge with existing data
312
+ existing_data = existing_doc.get("data", {})
313
+ existing_data.update(data)
314
+
315
+ # Increment version
316
+ version = existing_doc.get("version", 1) + 1
317
+
318
+ params_doc = {
319
+ "version": version,
320
+ "data": existing_data,
321
+ "updatedAt": datetime.utcnow().isoformat() + "Z"
322
+ }
323
+ else:
324
+ # Create new if corrupted
325
+ params_doc = {
326
+ "version": 1,
327
+ "data": data,
328
+ "createdAt": datetime.utcnow().isoformat() + "Z",
329
+ "updatedAt": datetime.utcnow().isoformat() + "Z"
330
+ }
331
+ else:
332
+ # Create new parameters document
333
+ params_doc = {
334
+ "version": 1,
335
+ "data": data,
336
+ "createdAt": datetime.utcnow().isoformat() + "Z",
337
+ "updatedAt": datetime.utcnow().isoformat() + "Z"
338
+ }
339
+
340
+ with open(params_file, "w") as f:
341
+ json.dump(params_doc, f, indent=2)
342
+
343
+ def read_parameters(
344
+ self,
345
+ project: str,
346
+ experiment: str,
347
+ ) -> Optional[Dict[str, Any]]:
348
+ """
349
+ Read parameters from local file.
350
+
351
+ Args:
352
+ project: Project name
353
+ experiment: Experiment name
354
+
355
+ Returns:
356
+ Flattened parameter dict, or None if file doesn't exist
357
+ """
358
+ experiment_dir = self._get_experiment_dir(project, experiment)
359
+ params_file = experiment_dir / "parameters.json"
360
+
361
+ if not params_file.exists():
362
+ return None
363
+
364
+ try:
365
+ with open(params_file, "r") as f:
366
+ params_doc = json.load(f)
367
+ return params_doc.get("data", {})
368
+ except (json.JSONDecodeError, IOError):
369
+ return None
370
+
371
+ def write_file(
372
+ self,
373
+ project: str,
374
+ experiment: str,
375
+ folder: Optional[str] = None,
376
+ file_path: str = "",
377
+ prefix: str = "",
378
+ filename: str = "",
379
+ description: Optional[str] = None,
380
+ tags: Optional[List[str]] = None,
381
+ metadata: Optional[Dict[str, Any]] = None,
382
+ checksum: str = "",
383
+ content_type: str = "",
384
+ size_bytes: int = 0
385
+ ) -> Dict[str, Any]:
386
+ """
387
+ Write file to local storage.
388
+
389
+ Copies file to: files/<prefix>/<file_id>/<filename>
390
+ Updates .files_metadata.json with file metadata
391
+
392
+ Args:
393
+ project: Project name
394
+ experiment: Experiment name
395
+ folder: Optional folder path
396
+ file_path: Source file path
397
+ prefix: Logical path prefix
398
+ filename: Original filename
399
+ description: Optional description
400
+ tags: Optional tags
401
+ metadata: Optional metadata
402
+ checksum: SHA256 checksum
403
+ content_type: MIME type
404
+ size_bytes: File size in bytes
405
+
406
+ Returns:
407
+ File metadata dict
408
+ """
409
+ import shutil
410
+ from .files import generate_snowflake_id
411
+
412
+ experiment_dir = self._get_experiment_dir(project, experiment, folder)
413
+ files_dir = experiment_dir / "files"
414
+ metadata_file = files_dir / ".files_metadata.json"
415
+
416
+ # Generate Snowflake ID for file
417
+ file_id = generate_snowflake_id()
418
+
419
+ # Normalize prefix (remove leading slashes to avoid absolute paths)
420
+ normalized_prefix = prefix.lstrip("/") if prefix else ""
421
+
422
+ # Create prefix directory, then file directory
423
+ prefix_dir = files_dir / normalized_prefix if normalized_prefix else files_dir
424
+ prefix_dir.mkdir(parents=True, exist_ok=True)
425
+
426
+ file_dir = prefix_dir / file_id
427
+ file_dir.mkdir(parents=True, exist_ok=True)
428
+
429
+ # Copy file
430
+ dest_file = file_dir / filename
431
+ shutil.copy2(file_path, dest_file)
432
+
433
+ # Create file metadata
434
+ file_metadata = {
435
+ "id": file_id,
436
+ "experimentId": f"{project}/{experiment}", # Local mode doesn't have real experiment ID
437
+ "path": prefix,
438
+ "filename": filename,
439
+ "description": description,
440
+ "tags": tags or [],
441
+ "contentType": content_type,
442
+ "sizeBytes": size_bytes,
443
+ "checksum": checksum,
444
+ "metadata": metadata,
445
+ "uploadedAt": datetime.utcnow().isoformat() + "Z",
446
+ "updatedAt": datetime.utcnow().isoformat() + "Z",
447
+ "deletedAt": None
448
+ }
449
+
450
+ # File-based lock for concurrent safety (works across processes/threads/instances)
451
+ lock_file = files_dir / ".files_metadata.lock"
452
+ with self._file_lock(lock_file):
453
+ # Read existing metadata
454
+ files_metadata = {"files": []}
455
+ if metadata_file.exists():
456
+ try:
457
+ with open(metadata_file, "r") as f:
458
+ files_metadata = json.load(f)
459
+ except (json.JSONDecodeError, IOError):
460
+ files_metadata = {"files": []}
461
+
462
+ # Check if file with same prefix+filename exists (overwrite behavior)
463
+ existing_index = None
464
+ for i, existing_file in enumerate(files_metadata["files"]):
465
+ if (existing_file["path"] == prefix and
466
+ existing_file["filename"] == filename and
467
+ existing_file["deletedAt"] is None):
468
+ existing_index = i
469
+ break
470
+
471
+ if existing_index is not None:
472
+ # Overwrite: remove old file and update metadata
473
+ old_file = files_metadata["files"][existing_index]
474
+ old_prefix = old_file["path"].lstrip("/") if old_file["path"] else ""
475
+ if old_prefix:
476
+ old_file_dir = files_dir / old_prefix / old_file["id"]
477
+ else:
478
+ old_file_dir = files_dir / old_file["id"]
479
+ if old_file_dir.exists():
480
+ shutil.rmtree(old_file_dir)
481
+ files_metadata["files"][existing_index] = file_metadata
482
+ else:
483
+ # New file: append to list
484
+ files_metadata["files"].append(file_metadata)
485
+
486
+ # Write updated metadata
487
+ with open(metadata_file, "w") as f:
488
+ json.dump(files_metadata, f, indent=2)
489
+
490
+ return file_metadata
491
+
492
+ def list_files(
493
+ self,
494
+ project: str,
495
+ experiment: str,
496
+ prefix: Optional[str] = None,
497
+ tags: Optional[List[str]] = None
498
+ ) -> List[Dict[str, Any]]:
499
+ """
500
+ List files from local storage.
501
+
502
+ Args:
503
+ project: Project name
504
+ experiment: Experiment name
505
+ prefix: Optional prefix filter
506
+ tags: Optional tags filter
507
+
508
+ Returns:
509
+ List of file metadata dicts (only non-deleted files)
510
+ """
511
+ experiment_dir = self._get_experiment_dir(project, experiment)
512
+ metadata_file = experiment_dir / "files" / ".files_metadata.json"
513
+
514
+ if not metadata_file.exists():
515
+ return []
516
+
517
+ try:
518
+ with open(metadata_file, "r") as f:
519
+ files_metadata = json.load(f)
520
+ except (json.JSONDecodeError, IOError):
521
+ return []
522
+
523
+ files = files_metadata.get("files", [])
524
+
525
+ # Filter out deleted files
526
+ files = [f for f in files if f.get("deletedAt") is None]
527
+
528
+ # Apply prefix filter
529
+ if prefix:
530
+ files = [f for f in files if f["path"].startswith(prefix)]
531
+
532
+ # Apply tags filter
533
+ if tags:
534
+ files = [f for f in files if any(tag in f.get("tags", []) for tag in tags)]
535
+
536
+ return files
537
+
538
+ def read_file(
539
+ self,
540
+ project: str,
541
+ experiment: str,
542
+ file_id: str,
543
+ dest_path: Optional[str] = None
544
+ ) -> str:
545
+ """
546
+ Read/copy file from local storage.
547
+
548
+ Args:
549
+ project: Project name
550
+ experiment: Experiment name
551
+ file_id: File ID
552
+ dest_path: Optional destination path (defaults to original filename)
553
+
554
+ Returns:
555
+ Path to copied file
556
+
557
+ Raises:
558
+ FileNotFoundError: If file not found
559
+ ValueError: If checksum verification fails
560
+ """
561
+ import shutil
562
+ from .files import verify_checksum
563
+
564
+ experiment_dir = self._get_experiment_dir(project, experiment)
565
+ files_dir = experiment_dir / "files"
566
+ metadata_file = files_dir / ".files_metadata.json"
567
+
568
+ if not metadata_file.exists():
569
+ raise FileNotFoundError(f"File {file_id} not found")
570
+
571
+ # Find file metadata
572
+ with open(metadata_file, "r") as f:
573
+ files_metadata = json.load(f)
574
+
575
+ file_metadata = None
576
+ for f in files_metadata.get("files", []):
577
+ if f["id"] == file_id and f.get("deletedAt") is None:
578
+ file_metadata = f
579
+ break
580
+
581
+ if not file_metadata:
582
+ raise FileNotFoundError(f"File {file_id} not found")
583
+
584
+ # Get source file
585
+ file_prefix = file_metadata["path"].lstrip("/") if file_metadata["path"] else ""
586
+ if file_prefix:
587
+ source_file = files_dir / file_prefix / file_id / file_metadata["filename"]
588
+ else:
589
+ source_file = files_dir / file_id / file_metadata["filename"]
590
+ if not source_file.exists():
591
+ raise FileNotFoundError(f"File {file_id} not found on disk")
592
+
593
+ # Determine destination
594
+ if dest_path is None:
595
+ dest_path = file_metadata["filename"]
596
+
597
+ # Copy file
598
+ shutil.copy2(source_file, dest_path)
599
+
600
+ # Verify checksum
601
+ expected_checksum = file_metadata["checksum"]
602
+ if not verify_checksum(dest_path, expected_checksum):
603
+ import os
604
+ os.remove(dest_path)
605
+ raise ValueError(f"Checksum verification failed for file {file_id}")
606
+
607
+ return dest_path
608
+
609
+ def delete_file(
610
+ self,
611
+ project: str,
612
+ experiment: str,
613
+ file_id: str
614
+ ) -> Dict[str, Any]:
615
+ """
616
+ Delete file from local storage (soft delete in metadata).
617
+
618
+ Args:
619
+ project: Project name
620
+ experiment: Experiment name
621
+ file_id: File ID
622
+
623
+ Returns:
624
+ Dict with id and deletedAt
625
+
626
+ Raises:
627
+ FileNotFoundError: If file not found
628
+ """
629
+ experiment_dir = self._get_experiment_dir(project, experiment)
630
+ metadata_file = experiment_dir / "files" / ".files_metadata.json"
631
+
632
+ if not metadata_file.exists():
633
+ raise FileNotFoundError(f"File {file_id} not found")
634
+
635
+ # File-based lock for concurrent safety (works across processes/threads/instances)
636
+ lock_file = files_dir / ".files_metadata.lock"
637
+ with self._file_lock(lock_file):
638
+ # Read metadata
639
+ with open(metadata_file, "r") as f:
640
+ files_metadata = json.load(f)
641
+
642
+ # Find and soft delete file
643
+ file_found = False
644
+ for file_meta in files_metadata.get("files", []):
645
+ if file_meta["id"] == file_id:
646
+ if file_meta.get("deletedAt") is not None:
647
+ raise FileNotFoundError(f"File {file_id} already deleted")
648
+ file_meta["deletedAt"] = datetime.utcnow().isoformat() + "Z"
649
+ file_meta["updatedAt"] = file_meta["deletedAt"]
650
+ file_found = True
651
+ break
652
+
653
+ if not file_found:
654
+ raise FileNotFoundError(f"File {file_id} not found")
655
+
656
+ # Write updated metadata
657
+ with open(metadata_file, "w") as f:
658
+ json.dump(files_metadata, f, indent=2)
659
+
660
+ return {
661
+ "id": file_id,
662
+ "deletedAt": file_meta["deletedAt"]
663
+ }
664
+
665
+ def update_file_metadata(
666
+ self,
667
+ project: str,
668
+ experiment: str,
669
+ file_id: str,
670
+ description: Optional[str] = None,
671
+ tags: Optional[List[str]] = None,
672
+ metadata: Optional[Dict[str, Any]] = None
673
+ ) -> Dict[str, Any]:
674
+ """
675
+ Update file metadata in local storage.
676
+
677
+ Args:
678
+ project: Project name
679
+ experiment: Experiment name
680
+ file_id: File ID
681
+ description: Optional description
682
+ tags: Optional tags
683
+ metadata: Optional metadata
684
+
685
+ Returns:
686
+ Updated file metadata dict
687
+
688
+ Raises:
689
+ FileNotFoundError: If file not found
690
+ """
691
+ experiment_dir = self._get_experiment_dir(project, experiment)
692
+ metadata_file = experiment_dir / "files" / ".files_metadata.json"
693
+
694
+ if not metadata_file.exists():
695
+ raise FileNotFoundError(f"File {file_id} not found")
696
+
697
+ # File-based lock for concurrent safety (works across processes/threads/instances)
698
+ lock_file = files_dir / ".files_metadata.lock"
699
+ with self._file_lock(lock_file):
700
+ # Read metadata
701
+ with open(metadata_file, "r") as f:
702
+ files_metadata = json.load(f)
703
+
704
+ # Find and update file
705
+ file_found = False
706
+ updated_file = None
707
+ for file_meta in files_metadata.get("files", []):
708
+ if file_meta["id"] == file_id:
709
+ if file_meta.get("deletedAt") is not None:
710
+ raise FileNotFoundError(f"File {file_id} has been deleted")
711
+
712
+ # Update fields
713
+ if description is not None:
714
+ file_meta["description"] = description
715
+ if tags is not None:
716
+ file_meta["tags"] = tags
717
+ if metadata is not None:
718
+ file_meta["metadata"] = metadata
719
+
720
+ file_meta["updatedAt"] = datetime.utcnow().isoformat() + "Z"
721
+ file_found = True
722
+ updated_file = file_meta
723
+ break
724
+
725
+ if not file_found:
726
+ raise FileNotFoundError(f"File {file_id} not found")
727
+
728
+ # Write updated metadata
729
+ with open(metadata_file, "w") as f:
730
+ json.dump(files_metadata, f, indent=2)
731
+
732
+ return updated_file
733
+
734
+ def _get_experiment_dir(self, project: str, experiment: str, folder: Optional[str] = None) -> Path:
735
+ """
736
+ Get experiment directory path.
737
+
738
+ If folder is not provided, tries to read it from experiment.json metadata.
739
+ Falls back to root_path/project/experiment if not found.
740
+ """
741
+ # If folder explicitly provided, use it
742
+ if folder is not None:
743
+ folder_path = folder.lstrip('/')
744
+ return self.root_path / folder_path / project / experiment
745
+
746
+ # Try to read folder from experiment metadata
747
+ # Check common locations where experiment might exist
748
+ possible_paths = []
749
+
750
+ # First, try without folder (most common case)
751
+ default_path = self.root_path / project / experiment
752
+ possible_paths.append(default_path)
753
+
754
+ # Then scan for experiment.json in subdirectories (for folder-based experiments)
755
+ try:
756
+ for item in self.root_path.rglob(f"*/{project}/{experiment}/experiment.json"):
757
+ exp_dir = item.parent
758
+ if exp_dir not in [p for p in possible_paths]:
759
+ possible_paths.insert(0, exp_dir) # Prioritize found paths
760
+ except:
761
+ pass
762
+
763
+ # Check each possible path for experiment.json with folder metadata
764
+ for path in possible_paths:
765
+ exp_json = path / "experiment.json"
766
+ if exp_json.exists():
767
+ try:
768
+ with open(exp_json, 'r') as f:
769
+ metadata = json.load(f)
770
+ if metadata.get('folder'):
771
+ folder_path = metadata['folder'].lstrip('/')
772
+ return self.root_path / folder_path / project / experiment
773
+ except:
774
+ pass
775
+ # Found experiment.json, use this path even if no folder metadata
776
+ return path
777
+
778
+ # Fallback to default path
779
+ return default_path
780
+
781
+ def append_to_metric(
782
+ self,
783
+ project: str,
784
+ experiment: str,
785
+ folder: Optional[str] = None,
786
+ metric_name: Optional[str] = None,
787
+ data: Optional[Dict[str, Any]] = None,
788
+ description: Optional[str] = None,
789
+ tags: Optional[List[str]] = None,
790
+ metadata: Optional[Dict[str, Any]] = None
791
+ ) -> Dict[str, Any]:
792
+ """
793
+ Append a single data point to a metric in local storage.
794
+
795
+ Storage format:
796
+ .ml-dash/{project}/{experiment}/metrics/{metric_name}/
797
+ data.jsonl # Data points (one JSON object per line)
798
+ metadata.json # Metric metadata (name, description, tags, stats)
799
+
800
+ Args:
801
+ project: Project name
802
+ experiment: Experiment name
803
+ folder: Optional folder path
804
+ metric_name: Metric name (None for unnamed metrics)
805
+ data: Data point (flexible schema)
806
+ description: Optional metric description
807
+ tags: Optional tags
808
+ metadata: Optional metric metadata
809
+
810
+ Returns:
811
+ Dict with metricId, index, bufferedDataPoints, chunkSize
812
+ """
813
+ if data is None:
814
+ data = {}
815
+ experiment_dir = self._get_experiment_dir(project, experiment, folder)
816
+ metrics_dir = experiment_dir / "metrics"
817
+ metrics_dir.mkdir(parents=True, exist_ok=True)
818
+
819
+ # Convert None to string for directory name
820
+ dir_name = str(metric_name) if metric_name is not None else "None"
821
+ metric_dir = metrics_dir / dir_name
822
+ metric_dir.mkdir(exist_ok=True)
823
+
824
+ data_file = metric_dir / "data.jsonl"
825
+ metadata_file = metric_dir / "metadata.json"
826
+
827
+ # File-based lock for concurrent metric appends (prevents index collision and count errors)
828
+ lock_file = metric_dir / ".metadata.lock"
829
+ with self._file_lock(lock_file):
830
+ # Load or initialize metadata
831
+ if metadata_file.exists():
832
+ try:
833
+ with open(metadata_file, "r") as f:
834
+ metric_meta = json.load(f)
835
+ except (json.JSONDecodeError, IOError):
836
+ # Corrupted metadata, reinitialize
837
+ metric_meta = {
838
+ "metricId": f"local-metric-{metric_name}",
839
+ "name": metric_name,
840
+ "description": description,
841
+ "tags": tags or [],
842
+ "metadata": metadata,
843
+ "totalDataPoints": 0,
844
+ "nextIndex": 0,
845
+ "createdAt": datetime.utcnow().isoformat() + "Z"
846
+ }
847
+ else:
848
+ metric_meta = {
849
+ "metricId": f"local-metric-{metric_name}",
850
+ "name": metric_name,
851
+ "description": description,
852
+ "tags": tags or [],
853
+ "metadata": metadata,
854
+ "totalDataPoints": 0,
855
+ "nextIndex": 0,
856
+ "createdAt": datetime.utcnow().isoformat() + "Z"
857
+ }
858
+
859
+ # Get next index
860
+ index = metric_meta["nextIndex"]
861
+
862
+ # Append data point to JSONL file
863
+ data_entry = {
864
+ "index": index,
865
+ "data": data,
866
+ "createdAt": datetime.utcnow().isoformat() + "Z"
867
+ }
868
+
869
+ with open(data_file, "a") as f:
870
+ f.write(json.dumps(data_entry) + "\n")
871
+
872
+ # Update metadata
873
+ metric_meta["nextIndex"] = index + 1
874
+ metric_meta["totalDataPoints"] = metric_meta["totalDataPoints"] + 1
875
+ metric_meta["updatedAt"] = datetime.utcnow().isoformat() + "Z"
876
+
877
+ with open(metadata_file, "w") as f:
878
+ json.dump(metric_meta, f, indent=2)
879
+
880
+ return {
881
+ "metricId": metric_meta["metricId"],
882
+ "index": str(index),
883
+ "bufferedDataPoints": str(metric_meta["totalDataPoints"]),
884
+ "chunkSize": 10000 # Default chunk size for local mode
885
+ }
886
+
887
+ def append_batch_to_metric(
888
+ self,
889
+ project: str,
890
+ experiment: str,
891
+ metric_name: Optional[str],
892
+ data_points: List[Dict[str, Any]],
893
+ description: Optional[str] = None,
894
+ tags: Optional[List[str]] = None,
895
+ metadata: Optional[Dict[str, Any]] = None
896
+ ) -> Dict[str, Any]:
897
+ """
898
+ Append multiple data points to a metric in local storage (batch).
899
+
900
+ Args:
901
+ project: Project name
902
+ experiment: Experiment name
903
+ metric_name: Metric name (None for unnamed metrics)
904
+ data_points: List of data points
905
+ description: Optional metric description
906
+ tags: Optional tags
907
+ metadata: Optional metric metadata
908
+
909
+ Returns:
910
+ Dict with metricId, startIndex, endIndex, count
911
+ """
912
+ experiment_dir = self._get_experiment_dir(project, experiment)
913
+ metrics_dir = experiment_dir / "metrics"
914
+ metrics_dir.mkdir(parents=True, exist_ok=True)
915
+
916
+ # Convert None to string for directory name
917
+ dir_name = str(metric_name) if metric_name is not None else "None"
918
+ metric_dir = metrics_dir / dir_name
919
+ metric_dir.mkdir(exist_ok=True)
920
+
921
+ data_file = metric_dir / "data.jsonl"
922
+ metadata_file = metric_dir / "metadata.json"
923
+
924
+ # File-based lock for concurrent batch appends (prevents index collision and count errors)
925
+ lock_file = metric_dir / ".metadata.lock"
926
+ with self._file_lock(lock_file):
927
+ # Load or initialize metadata
928
+ if metadata_file.exists():
929
+ try:
930
+ with open(metadata_file, "r") as f:
931
+ metric_meta = json.load(f)
932
+ except (json.JSONDecodeError, IOError):
933
+ # Corrupted metadata, reinitialize
934
+ metric_meta = {
935
+ "metricId": f"local-metric-{metric_name}",
936
+ "name": metric_name,
937
+ "description": description,
938
+ "tags": tags or [],
939
+ "metadata": metadata,
940
+ "totalDataPoints": 0,
941
+ "nextIndex": 0,
942
+ "createdAt": datetime.utcnow().isoformat() + "Z"
943
+ }
944
+ else:
945
+ metric_meta = {
946
+ "metricId": f"local-metric-{metric_name}",
947
+ "name": metric_name,
948
+ "description": description,
949
+ "tags": tags or [],
950
+ "metadata": metadata,
951
+ "totalDataPoints": 0,
952
+ "nextIndex": 0,
953
+ "createdAt": datetime.utcnow().isoformat() + "Z"
954
+ }
955
+
956
+ start_index = metric_meta["nextIndex"]
957
+ end_index = start_index + len(data_points) - 1
958
+
959
+ # Append data points to JSONL file
960
+ with open(data_file, "a") as f:
961
+ for i, data in enumerate(data_points):
962
+ data_entry = {
963
+ "index": start_index + i,
964
+ "data": data,
965
+ "createdAt": datetime.utcnow().isoformat() + "Z"
966
+ }
967
+ f.write(json.dumps(data_entry) + "\n")
968
+
969
+ # Update metadata
970
+ metric_meta["nextIndex"] = end_index + 1
971
+ metric_meta["totalDataPoints"] = metric_meta["totalDataPoints"] + len(data_points)
972
+ metric_meta["updatedAt"] = datetime.utcnow().isoformat() + "Z"
973
+
974
+ with open(metadata_file, "w") as f:
975
+ json.dump(metric_meta, f, indent=2)
976
+
977
+ return {
978
+ "metricId": metric_meta["metricId"],
979
+ "startIndex": str(start_index),
980
+ "endIndex": str(end_index),
981
+ "count": len(data_points),
982
+ "bufferedDataPoints": str(metric_meta["totalDataPoints"]),
983
+ "chunkSize": 10000
984
+ }
985
+
986
+ def read_metric_data(
987
+ self,
988
+ project: str,
989
+ experiment: str,
990
+ metric_name: str,
991
+ start_index: int = 0,
992
+ limit: int = 1000
993
+ ) -> Dict[str, Any]:
994
+ """
995
+ Read data points from a metric in local storage.
996
+
997
+ Args:
998
+ project: Project name
999
+ experiment: Experiment name
1000
+ metric_name: Metric name
1001
+ start_index: Starting index
1002
+ limit: Max points to read
1003
+
1004
+ Returns:
1005
+ Dict with data, startIndex, endIndex, total, hasMore
1006
+ """
1007
+ experiment_dir = self._get_experiment_dir(project, experiment)
1008
+ metric_dir = experiment_dir / "metrics" / metric_name
1009
+ data_file = metric_dir / "data.jsonl"
1010
+
1011
+ if not data_file.exists():
1012
+ return {
1013
+ "data": [],
1014
+ "startIndex": start_index,
1015
+ "endIndex": start_index - 1,
1016
+ "total": 0,
1017
+ "hasMore": False
1018
+ }
1019
+
1020
+ # Read all data points from JSONL file
1021
+ data_points = []
1022
+ with open(data_file, "r") as f:
1023
+ for line in f:
1024
+ if line.strip():
1025
+ entry = json.loads(line)
1026
+ # Filter by index range
1027
+ if start_index <= entry["index"] < start_index + limit:
1028
+ data_points.append(entry)
1029
+
1030
+ # Get total count
1031
+ metadata_file = metric_dir / "metadata.json"
1032
+ total_count = 0
1033
+ if metadata_file.exists():
1034
+ with open(metadata_file, "r") as f:
1035
+ metric_meta = json.load(f)
1036
+ total_count = metric_meta["totalDataPoints"]
1037
+
1038
+ return {
1039
+ "data": data_points,
1040
+ "startIndex": start_index,
1041
+ "endIndex": start_index + len(data_points) - 1 if data_points else start_index - 1,
1042
+ "total": len(data_points),
1043
+ "hasMore": start_index + len(data_points) < total_count
1044
+ }
1045
+
1046
+ def get_metric_stats(
1047
+ self,
1048
+ project: str,
1049
+ experiment: str,
1050
+ metric_name: str
1051
+ ) -> Dict[str, Any]:
1052
+ """
1053
+ Get metric statistics from local storage.
1054
+
1055
+ Args:
1056
+ project: Project name
1057
+ experiment: Experiment name
1058
+ metric_name: Metric name
1059
+
1060
+ Returns:
1061
+ Dict with metric stats
1062
+ """
1063
+ experiment_dir = self._get_experiment_dir(project, experiment)
1064
+ metric_dir = experiment_dir / "metrics" / metric_name
1065
+ metadata_file = metric_dir / "metadata.json"
1066
+
1067
+ if not metadata_file.exists():
1068
+ raise FileNotFoundError(f"Metric {metric_name} not found")
1069
+
1070
+ with open(metadata_file, "r") as f:
1071
+ metric_meta = json.load(f)
1072
+
1073
+ return {
1074
+ "metricId": metric_meta["metricId"],
1075
+ "name": metric_meta["name"],
1076
+ "description": metric_meta.get("description"),
1077
+ "tags": metric_meta.get("tags", []),
1078
+ "metadata": metric_meta.get("metadata"),
1079
+ "totalDataPoints": str(metric_meta["totalDataPoints"]),
1080
+ "bufferedDataPoints": str(metric_meta["totalDataPoints"]), # All buffered in local mode
1081
+ "chunkedDataPoints": "0", # No chunking in local mode
1082
+ "totalChunks": 0,
1083
+ "chunkSize": 10000,
1084
+ "firstDataAt": metric_meta.get("createdAt"),
1085
+ "lastDataAt": metric_meta.get("updatedAt"),
1086
+ "createdAt": metric_meta.get("createdAt"),
1087
+ "updatedAt": metric_meta.get("updatedAt", metric_meta.get("createdAt"))
1088
+ }
1089
+
1090
+ def list_metrics(
1091
+ self,
1092
+ project: str,
1093
+ experiment: str
1094
+ ) -> List[Dict[str, Any]]:
1095
+ """
1096
+ List all metrics in an experiment from local storage.
1097
+
1098
+ Args:
1099
+ project: Project name
1100
+ experiment: Experiment name
1101
+
1102
+ Returns:
1103
+ List of metric summaries
1104
+ """
1105
+ experiment_dir = self._get_experiment_dir(project, experiment)
1106
+ metrics_dir = experiment_dir / "metrics"
1107
+
1108
+ if not metrics_dir.exists():
1109
+ return []
1110
+
1111
+ metrics = []
1112
+ for metric_dir in metrics_dir.iterdir():
1113
+ if metric_dir.is_dir():
1114
+ metadata_file = metric_dir / "metadata.json"
1115
+ if metadata_file.exists():
1116
+ with open(metadata_file, "r") as f:
1117
+ metric_meta = json.load(f)
1118
+ metrics.append({
1119
+ "metricId": metric_meta["metricId"],
1120
+ "name": metric_meta["name"],
1121
+ "description": metric_meta.get("description"),
1122
+ "tags": metric_meta.get("tags", []),
1123
+ "totalDataPoints": str(metric_meta["totalDataPoints"]),
1124
+ "createdAt": metric_meta.get("createdAt")
1125
+ })
1126
+
1127
+ return metrics