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