ml-dash 0.6.2rc1__py3-none-any.whl → 0.6.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ml_dash/__init__.py +36 -64
- ml_dash/auth/token_storage.py +267 -226
- ml_dash/auto_start.py +28 -15
- ml_dash/cli.py +16 -2
- ml_dash/cli_commands/api.py +165 -0
- ml_dash/cli_commands/download.py +757 -667
- ml_dash/cli_commands/list.py +146 -13
- ml_dash/cli_commands/login.py +190 -183
- ml_dash/cli_commands/profile.py +92 -0
- ml_dash/cli_commands/upload.py +1291 -1141
- ml_dash/client.py +79 -6
- ml_dash/config.py +119 -119
- ml_dash/experiment.py +1234 -1034
- ml_dash/files.py +339 -224
- ml_dash/log.py +7 -7
- ml_dash/metric.py +359 -100
- ml_dash/params.py +6 -6
- ml_dash/remote_auto_start.py +20 -17
- ml_dash/run.py +211 -65
- ml_dash/snowflake.py +173 -0
- ml_dash/storage.py +1051 -1081
- {ml_dash-0.6.2rc1.dist-info → ml_dash-0.6.3.dist-info}/METADATA +12 -14
- ml_dash-0.6.3.dist-info/RECORD +33 -0
- {ml_dash-0.6.2rc1.dist-info → ml_dash-0.6.3.dist-info}/WHEEL +1 -1
- ml_dash-0.6.2rc1.dist-info/RECORD +0 -30
- {ml_dash-0.6.2rc1.dist-info → ml_dash-0.6.3.dist-info}/entry_points.txt +0 -0
ml_dash/storage.py
CHANGED
|
@@ -2,1128 +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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
Initialize local storage.
|
|
310
|
+
# Increment version
|
|
311
|
+
version = existing_doc.get("version", 1) + 1
|
|
38
312
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
"
|
|
446
|
-
"
|
|
447
|
-
"
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
project:
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
519
|
-
|
|
899
|
+
with open(metadata_file, "r") as f:
|
|
900
|
+
metric_meta = json.load(f)
|
|
520
901
|
except (json.JSONDecodeError, IOError):
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
582
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
files_dir = experiment_dir / "files"
|
|
631
|
-
metadata_file = files_dir / ".files_metadata.json"
|
|
632
|
-
|
|
633
|
-
if not metadata_file.exists():
|
|
634
|
-
raise FileNotFoundError(f"File {file_id} not found")
|
|
635
|
-
|
|
636
|
-
# File-based lock for concurrent safety (works across processes/threads/instances)
|
|
637
|
-
lock_file = files_dir / ".files_metadata.lock"
|
|
638
|
-
with self._file_lock(lock_file):
|
|
639
|
-
# Read metadata
|
|
640
|
-
with open(metadata_file, "r") as f:
|
|
641
|
-
files_metadata = json.load(f)
|
|
642
|
-
|
|
643
|
-
# Find and soft delete file
|
|
644
|
-
file_found = False
|
|
645
|
-
for file_meta in files_metadata.get("files", []):
|
|
646
|
-
if file_meta["id"] == file_id:
|
|
647
|
-
if file_meta.get("deletedAt") is not None:
|
|
648
|
-
raise FileNotFoundError(f"File {file_id} already deleted")
|
|
649
|
-
file_meta["deletedAt"] = datetime.utcnow().isoformat() + "Z"
|
|
650
|
-
file_meta["updatedAt"] = file_meta["deletedAt"]
|
|
651
|
-
file_found = True
|
|
652
|
-
break
|
|
653
|
-
|
|
654
|
-
if not file_found:
|
|
655
|
-
raise FileNotFoundError(f"File {file_id} not found")
|
|
656
|
-
|
|
657
|
-
# Write updated metadata
|
|
658
|
-
with open(metadata_file, "w") as f:
|
|
659
|
-
json.dump(files_metadata, f, indent=2)
|
|
660
|
-
|
|
661
|
-
return {
|
|
662
|
-
"id": file_id,
|
|
663
|
-
"deletedAt": file_meta["deletedAt"]
|
|
664
|
-
}
|
|
1025
|
+
Args:
|
|
1026
|
+
owner: Owner/user
|
|
1027
|
+
project: Project name
|
|
1028
|
+
prefix: Experiment prefix
|
|
1029
|
+
metric_name: Metric name
|
|
665
1030
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
with self._file_lock(lock_file):
|
|
702
|
-
# Read metadata
|
|
703
|
-
with open(metadata_file, "r") as f:
|
|
704
|
-
files_metadata = json.load(f)
|
|
705
|
-
|
|
706
|
-
# Find and update file
|
|
707
|
-
file_found = False
|
|
708
|
-
updated_file = None
|
|
709
|
-
for file_meta in files_metadata.get("files", []):
|
|
710
|
-
if file_meta["id"] == file_id:
|
|
711
|
-
if file_meta.get("deletedAt") is not None:
|
|
712
|
-
raise FileNotFoundError(f"File {file_id} has been deleted")
|
|
713
|
-
|
|
714
|
-
# Update fields
|
|
715
|
-
if description is not None:
|
|
716
|
-
file_meta["description"] = description
|
|
717
|
-
if tags is not None:
|
|
718
|
-
file_meta["tags"] = tags
|
|
719
|
-
if metadata is not None:
|
|
720
|
-
file_meta["metadata"] = metadata
|
|
721
|
-
|
|
722
|
-
file_meta["updatedAt"] = datetime.utcnow().isoformat() + "Z"
|
|
723
|
-
file_found = True
|
|
724
|
-
updated_file = file_meta
|
|
725
|
-
break
|
|
726
|
-
|
|
727
|
-
if not file_found:
|
|
728
|
-
raise FileNotFoundError(f"File {file_id} not found")
|
|
729
|
-
|
|
730
|
-
# Write updated metadata
|
|
731
|
-
with open(metadata_file, "w") as f:
|
|
732
|
-
json.dump(files_metadata, f, indent=2)
|
|
733
|
-
|
|
734
|
-
return updated_file
|
|
735
|
-
|
|
736
|
-
def _get_experiment_dir(self, project: str, experiment: str, folder: Optional[str] = None) -> Path:
|
|
737
|
-
"""
|
|
738
|
-
Get experiment directory path.
|
|
739
|
-
|
|
740
|
-
If folder is not provided, tries to read it from experiment.json metadata.
|
|
741
|
-
Falls back to root_path/project/experiment if not found.
|
|
742
|
-
"""
|
|
743
|
-
# If folder explicitly provided, use it
|
|
744
|
-
if folder is not None:
|
|
745
|
-
folder_path = folder.lstrip('/')
|
|
746
|
-
return self.root_path / folder_path / project / experiment
|
|
747
|
-
|
|
748
|
-
# Try to read folder from experiment metadata
|
|
749
|
-
# Check common locations where experiment might exist
|
|
750
|
-
possible_paths = []
|
|
751
|
-
|
|
752
|
-
# First, try without folder (most common case)
|
|
753
|
-
default_path = self.root_path / project / experiment
|
|
754
|
-
possible_paths.append(default_path)
|
|
755
|
-
|
|
756
|
-
# Then scan for experiment.json in subdirectories (for folder-based experiments)
|
|
757
|
-
try:
|
|
758
|
-
for item in self.root_path.rglob(f"*/{project}/{experiment}/experiment.json"):
|
|
759
|
-
exp_dir = item.parent
|
|
760
|
-
if exp_dir not in [p for p in possible_paths]:
|
|
761
|
-
possible_paths.insert(0, exp_dir) # Prioritize found paths
|
|
762
|
-
except:
|
|
763
|
-
pass
|
|
764
|
-
|
|
765
|
-
# Check each possible path for experiment.json with folder metadata
|
|
766
|
-
for path in possible_paths:
|
|
767
|
-
exp_json = path / "experiment.json"
|
|
768
|
-
if exp_json.exists():
|
|
769
|
-
try:
|
|
770
|
-
with open(exp_json, 'r') as f:
|
|
771
|
-
metadata = json.load(f)
|
|
772
|
-
if metadata.get('folder'):
|
|
773
|
-
folder_path = metadata['folder'].lstrip('/')
|
|
774
|
-
return self.root_path / folder_path / project / experiment
|
|
775
|
-
except:
|
|
776
|
-
pass
|
|
777
|
-
# Found experiment.json, use this path even if no folder metadata
|
|
778
|
-
return path
|
|
779
|
-
|
|
780
|
-
# Fallback to default path
|
|
781
|
-
return default_path
|
|
782
|
-
|
|
783
|
-
def append_to_metric(
|
|
784
|
-
self,
|
|
785
|
-
project: str,
|
|
786
|
-
experiment: str,
|
|
787
|
-
folder: Optional[str] = None,
|
|
788
|
-
metric_name: Optional[str] = None,
|
|
789
|
-
data: Optional[Dict[str, Any]] = None,
|
|
790
|
-
description: Optional[str] = None,
|
|
791
|
-
tags: Optional[List[str]] = None,
|
|
792
|
-
metadata: Optional[Dict[str, Any]] = None
|
|
793
|
-
) -> Dict[str, Any]:
|
|
794
|
-
"""
|
|
795
|
-
Append a single data point to a metric in local storage.
|
|
796
|
-
|
|
797
|
-
Storage format:
|
|
798
|
-
.ml-dash/{project}/{experiment}/metrics/{metric_name}/
|
|
799
|
-
data.jsonl # Data points (one JSON object per line)
|
|
800
|
-
metadata.json # Metric metadata (name, description, tags, stats)
|
|
801
|
-
|
|
802
|
-
Args:
|
|
803
|
-
project: Project name
|
|
804
|
-
experiment: Experiment name
|
|
805
|
-
folder: Optional folder path
|
|
806
|
-
metric_name: Metric name (None for unnamed metrics)
|
|
807
|
-
data: Data point (flexible schema)
|
|
808
|
-
description: Optional metric description
|
|
809
|
-
tags: Optional tags
|
|
810
|
-
metadata: Optional metric metadata
|
|
811
|
-
|
|
812
|
-
Returns:
|
|
813
|
-
Dict with metricId, index, bufferedDataPoints, chunkSize
|
|
814
|
-
"""
|
|
815
|
-
if data is None:
|
|
816
|
-
data = {}
|
|
817
|
-
experiment_dir = self._get_experiment_dir(project, experiment, folder)
|
|
818
|
-
metrics_dir = experiment_dir / "metrics"
|
|
819
|
-
metrics_dir.mkdir(parents=True, exist_ok=True)
|
|
820
|
-
|
|
821
|
-
# Convert None to string for directory name
|
|
822
|
-
dir_name = str(metric_name) if metric_name is not None else "None"
|
|
823
|
-
metric_dir = metrics_dir / dir_name
|
|
824
|
-
metric_dir.mkdir(exist_ok=True)
|
|
825
|
-
|
|
826
|
-
data_file = metric_dir / "data.jsonl"
|
|
827
|
-
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.
|
|
828
1066
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
if metadata_file.exists():
|
|
834
|
-
try:
|
|
835
|
-
with open(metadata_file, "r") as f:
|
|
836
|
-
metric_meta = json.load(f)
|
|
837
|
-
except (json.JSONDecodeError, IOError):
|
|
838
|
-
# Corrupted metadata, reinitialize
|
|
839
|
-
metric_meta = {
|
|
840
|
-
"metricId": f"local-metric-{metric_name}",
|
|
841
|
-
"name": metric_name,
|
|
842
|
-
"description": description,
|
|
843
|
-
"tags": tags or [],
|
|
844
|
-
"metadata": metadata,
|
|
845
|
-
"totalDataPoints": 0,
|
|
846
|
-
"nextIndex": 0,
|
|
847
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
848
|
-
}
|
|
849
|
-
else:
|
|
850
|
-
metric_meta = {
|
|
851
|
-
"metricId": f"local-metric-{metric_name}",
|
|
852
|
-
"name": metric_name,
|
|
853
|
-
"description": description,
|
|
854
|
-
"tags": tags or [],
|
|
855
|
-
"metadata": metadata,
|
|
856
|
-
"totalDataPoints": 0,
|
|
857
|
-
"nextIndex": 0,
|
|
858
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
# Get next index
|
|
862
|
-
index = metric_meta["nextIndex"]
|
|
863
|
-
|
|
864
|
-
# Append data point to JSONL file
|
|
865
|
-
data_entry = {
|
|
866
|
-
"index": index,
|
|
867
|
-
"data": data,
|
|
868
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
with open(data_file, "a") as f:
|
|
872
|
-
f.write(json.dumps(data_entry) + "\n")
|
|
873
|
-
|
|
874
|
-
# Update metadata
|
|
875
|
-
metric_meta["nextIndex"] = index + 1
|
|
876
|
-
metric_meta["totalDataPoints"] = metric_meta["totalDataPoints"] + 1
|
|
877
|
-
metric_meta["updatedAt"] = datetime.utcnow().isoformat() + "Z"
|
|
878
|
-
|
|
879
|
-
with open(metadata_file, "w") as f:
|
|
880
|
-
json.dump(metric_meta, f, indent=2)
|
|
881
|
-
|
|
882
|
-
return {
|
|
883
|
-
"metricId": metric_meta["metricId"],
|
|
884
|
-
"index": str(index),
|
|
885
|
-
"bufferedDataPoints": str(metric_meta["totalDataPoints"]),
|
|
886
|
-
"chunkSize": 10000 # Default chunk size for local mode
|
|
887
|
-
}
|
|
1067
|
+
Args:
|
|
1068
|
+
owner: Owner/user
|
|
1069
|
+
project: Project name
|
|
1070
|
+
prefix: Experiment prefix
|
|
888
1071
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
data_points: List[Dict[str, Any]],
|
|
895
|
-
description: Optional[str] = None,
|
|
896
|
-
tags: Optional[List[str]] = None,
|
|
897
|
-
metadata: Optional[Dict[str, Any]] = None
|
|
898
|
-
) -> Dict[str, Any]:
|
|
899
|
-
"""
|
|
900
|
-
Append multiple data points to a metric in local storage (batch).
|
|
901
|
-
|
|
902
|
-
Args:
|
|
903
|
-
project: Project name
|
|
904
|
-
experiment: Experiment name
|
|
905
|
-
metric_name: Metric name (None for unnamed metrics)
|
|
906
|
-
data_points: List of data points
|
|
907
|
-
description: Optional metric description
|
|
908
|
-
tags: Optional tags
|
|
909
|
-
metadata: Optional metric metadata
|
|
910
|
-
|
|
911
|
-
Returns:
|
|
912
|
-
Dict with metricId, startIndex, endIndex, count
|
|
913
|
-
"""
|
|
914
|
-
experiment_dir = self._get_experiment_dir(project, experiment)
|
|
915
|
-
metrics_dir = experiment_dir / "metrics"
|
|
916
|
-
metrics_dir.mkdir(parents=True, exist_ok=True)
|
|
917
|
-
|
|
918
|
-
# Convert None to string for directory name
|
|
919
|
-
dir_name = str(metric_name) if metric_name is not None else "None"
|
|
920
|
-
metric_dir = metrics_dir / dir_name
|
|
921
|
-
metric_dir.mkdir(exist_ok=True)
|
|
922
|
-
|
|
923
|
-
data_file = metric_dir / "data.jsonl"
|
|
924
|
-
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"
|
|
925
1077
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
with self._file_lock(lock_file):
|
|
929
|
-
# Load or initialize metadata
|
|
930
|
-
if metadata_file.exists():
|
|
931
|
-
try:
|
|
932
|
-
with open(metadata_file, "r") as f:
|
|
933
|
-
metric_meta = json.load(f)
|
|
934
|
-
except (json.JSONDecodeError, IOError):
|
|
935
|
-
# Corrupted metadata, reinitialize
|
|
936
|
-
metric_meta = {
|
|
937
|
-
"metricId": f"local-metric-{metric_name}",
|
|
938
|
-
"name": metric_name,
|
|
939
|
-
"description": description,
|
|
940
|
-
"tags": tags or [],
|
|
941
|
-
"metadata": metadata,
|
|
942
|
-
"totalDataPoints": 0,
|
|
943
|
-
"nextIndex": 0,
|
|
944
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
945
|
-
}
|
|
946
|
-
else:
|
|
947
|
-
metric_meta = {
|
|
948
|
-
"metricId": f"local-metric-{metric_name}",
|
|
949
|
-
"name": metric_name,
|
|
950
|
-
"description": description,
|
|
951
|
-
"tags": tags or [],
|
|
952
|
-
"metadata": metadata,
|
|
953
|
-
"totalDataPoints": 0,
|
|
954
|
-
"nextIndex": 0,
|
|
955
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
start_index = metric_meta["nextIndex"]
|
|
959
|
-
end_index = start_index + len(data_points) - 1
|
|
960
|
-
|
|
961
|
-
# Append data points to JSONL file
|
|
962
|
-
with open(data_file, "a") as f:
|
|
963
|
-
for i, data in enumerate(data_points):
|
|
964
|
-
data_entry = {
|
|
965
|
-
"index": start_index + i,
|
|
966
|
-
"data": data,
|
|
967
|
-
"createdAt": datetime.utcnow().isoformat() + "Z"
|
|
968
|
-
}
|
|
969
|
-
f.write(json.dumps(data_entry) + "\n")
|
|
970
|
-
|
|
971
|
-
# Update metadata
|
|
972
|
-
metric_meta["nextIndex"] = end_index + 1
|
|
973
|
-
metric_meta["totalDataPoints"] = metric_meta["totalDataPoints"] + len(data_points)
|
|
974
|
-
metric_meta["updatedAt"] = datetime.utcnow().isoformat() + "Z"
|
|
975
|
-
|
|
976
|
-
with open(metadata_file, "w") as f:
|
|
977
|
-
json.dump(metric_meta, f, indent=2)
|
|
978
|
-
|
|
979
|
-
return {
|
|
980
|
-
"metricId": metric_meta["metricId"],
|
|
981
|
-
"startIndex": str(start_index),
|
|
982
|
-
"endIndex": str(end_index),
|
|
983
|
-
"count": len(data_points),
|
|
984
|
-
"bufferedDataPoints": str(metric_meta["totalDataPoints"]),
|
|
985
|
-
"chunkSize": 10000
|
|
986
|
-
}
|
|
1078
|
+
if not metrics_dir.exists():
|
|
1079
|
+
return []
|
|
987
1080
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
experiment: str,
|
|
992
|
-
metric_name: str,
|
|
993
|
-
start_index: int = 0,
|
|
994
|
-
limit: int = 1000
|
|
995
|
-
) -> Dict[str, Any]:
|
|
996
|
-
"""
|
|
997
|
-
Read data points from a metric in local storage.
|
|
998
|
-
|
|
999
|
-
Args:
|
|
1000
|
-
project: Project name
|
|
1001
|
-
experiment: Experiment name
|
|
1002
|
-
metric_name: Metric name
|
|
1003
|
-
start_index: Starting index
|
|
1004
|
-
limit: Max points to read
|
|
1005
|
-
|
|
1006
|
-
Returns:
|
|
1007
|
-
Dict with data, startIndex, endIndex, total, hasMore
|
|
1008
|
-
"""
|
|
1009
|
-
experiment_dir = self._get_experiment_dir(project, experiment)
|
|
1010
|
-
metric_dir = experiment_dir / "metrics" / metric_name
|
|
1011
|
-
data_file = metric_dir / "data.jsonl"
|
|
1012
|
-
|
|
1013
|
-
if not data_file.exists():
|
|
1014
|
-
return {
|
|
1015
|
-
"data": [],
|
|
1016
|
-
"startIndex": start_index,
|
|
1017
|
-
"endIndex": start_index - 1,
|
|
1018
|
-
"total": 0,
|
|
1019
|
-
"hasMore": False
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
# Read all data points from JSONL file
|
|
1023
|
-
data_points = []
|
|
1024
|
-
with open(data_file, "r") as f:
|
|
1025
|
-
for line in f:
|
|
1026
|
-
if line.strip():
|
|
1027
|
-
entry = json.loads(line)
|
|
1028
|
-
# Filter by index range
|
|
1029
|
-
if start_index <= entry["index"] < start_index + limit:
|
|
1030
|
-
data_points.append(entry)
|
|
1031
|
-
|
|
1032
|
-
# Get total count
|
|
1081
|
+
metrics = []
|
|
1082
|
+
for metric_dir in metrics_dir.iterdir():
|
|
1083
|
+
if metric_dir.is_dir():
|
|
1033
1084
|
metadata_file = metric_dir / "metadata.json"
|
|
1034
|
-
total_count = 0
|
|
1035
1085
|
if metadata_file.exists():
|
|
1036
|
-
|
|
1037
|
-
metric_meta = json.load(f)
|
|
1038
|
-
total_count = metric_meta["totalDataPoints"]
|
|
1039
|
-
|
|
1040
|
-
return {
|
|
1041
|
-
"data": data_points,
|
|
1042
|
-
"startIndex": start_index,
|
|
1043
|
-
"endIndex": start_index + len(data_points) - 1 if data_points else start_index - 1,
|
|
1044
|
-
"total": len(data_points),
|
|
1045
|
-
"hasMore": start_index + len(data_points) < total_count
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
def get_metric_stats(
|
|
1049
|
-
self,
|
|
1050
|
-
project: str,
|
|
1051
|
-
experiment: str,
|
|
1052
|
-
metric_name: str
|
|
1053
|
-
) -> Dict[str, Any]:
|
|
1054
|
-
"""
|
|
1055
|
-
Get metric statistics from local storage.
|
|
1056
|
-
|
|
1057
|
-
Args:
|
|
1058
|
-
project: Project name
|
|
1059
|
-
experiment: Experiment name
|
|
1060
|
-
metric_name: Metric name
|
|
1061
|
-
|
|
1062
|
-
Returns:
|
|
1063
|
-
Dict with metric stats
|
|
1064
|
-
"""
|
|
1065
|
-
experiment_dir = self._get_experiment_dir(project, experiment)
|
|
1066
|
-
metric_dir = experiment_dir / "metrics" / metric_name
|
|
1067
|
-
metadata_file = metric_dir / "metadata.json"
|
|
1068
|
-
|
|
1069
|
-
if not metadata_file.exists():
|
|
1070
|
-
raise FileNotFoundError(f"Metric {metric_name} not found")
|
|
1071
|
-
|
|
1072
|
-
with open(metadata_file, "r") as f:
|
|
1086
|
+
with open(metadata_file, "r") as f:
|
|
1073
1087
|
metric_meta = json.load(f)
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
"firstDataAt": metric_meta.get("createdAt"),
|
|
1087
|
-
"lastDataAt": metric_meta.get("updatedAt"),
|
|
1088
|
-
"createdAt": metric_meta.get("createdAt"),
|
|
1089
|
-
"updatedAt": metric_meta.get("updatedAt", metric_meta.get("createdAt"))
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
def list_metrics(
|
|
1093
|
-
self,
|
|
1094
|
-
project: str,
|
|
1095
|
-
experiment: str
|
|
1096
|
-
) -> List[Dict[str, Any]]:
|
|
1097
|
-
"""
|
|
1098
|
-
List all metrics in an experiment from local storage.
|
|
1099
|
-
|
|
1100
|
-
Args:
|
|
1101
|
-
project: Project name
|
|
1102
|
-
experiment: Experiment name
|
|
1103
|
-
|
|
1104
|
-
Returns:
|
|
1105
|
-
List of metric summaries
|
|
1106
|
-
"""
|
|
1107
|
-
experiment_dir = self._get_experiment_dir(project, experiment)
|
|
1108
|
-
metrics_dir = experiment_dir / "metrics"
|
|
1109
|
-
|
|
1110
|
-
if not metrics_dir.exists():
|
|
1111
|
-
return []
|
|
1112
|
-
|
|
1113
|
-
metrics = []
|
|
1114
|
-
for metric_dir in metrics_dir.iterdir():
|
|
1115
|
-
if metric_dir.is_dir():
|
|
1116
|
-
metadata_file = metric_dir / "metadata.json"
|
|
1117
|
-
if metadata_file.exists():
|
|
1118
|
-
with open(metadata_file, "r") as f:
|
|
1119
|
-
metric_meta = json.load(f)
|
|
1120
|
-
metrics.append({
|
|
1121
|
-
"metricId": metric_meta["metricId"],
|
|
1122
|
-
"name": metric_meta["name"],
|
|
1123
|
-
"description": metric_meta.get("description"),
|
|
1124
|
-
"tags": metric_meta.get("tags", []),
|
|
1125
|
-
"totalDataPoints": str(metric_meta["totalDataPoints"]),
|
|
1126
|
-
"createdAt": metric_meta.get("createdAt")
|
|
1127
|
-
})
|
|
1128
|
-
|
|
1129
|
-
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
|