ml-dash 0.4.0__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ml_dash/__init__.py CHANGED
@@ -1,14 +1,58 @@
1
- """ML-Logger: A minimal, local-first experiment tracking library."""
1
+ """
2
+ ML-Dash Python SDK
2
3
 
3
- from .run import Experiment
4
- from .ml_logger import ML_Logger, LogLevel
5
- from .job_logger import JobLogger
4
+ A simple and flexible SDK for ML experiment metricing and data storage.
6
5
 
7
- __version__ = "0.4.0"
6
+ Usage:
7
+
8
+ # Remote mode (API server)
9
+ from ml_dash import Experiment
10
+
11
+ with Experiment(
12
+ name="my-experiment",
13
+ project="my-project",
14
+ remote="http://localhost:3000",
15
+ api_key="your-jwt-token"
16
+ ) as experiment:
17
+ experiment.log("Training started")
18
+ experiment.metric("loss", {"step": 0, "value": 0.5})
19
+
20
+ # Local mode (filesystem)
21
+ with Experiment(
22
+ name="my-experiment",
23
+ project="my-project",
24
+ local_path=".ml-dash"
25
+ ) as experiment:
26
+ experiment.log("Training started")
27
+
28
+ # Decorator style
29
+ from ml_dash import ml_dash_experiment
30
+
31
+ @ml_dash_experiment(
32
+ name="my-experiment",
33
+ project="my-project",
34
+ remote="http://localhost:3000",
35
+ api_key="your-jwt-token"
36
+ )
37
+ def train_model(experiment):
38
+ experiment.log("Training started")
39
+ """
40
+
41
+ from .experiment import Experiment, ml_dash_experiment, OperationMode
42
+ from .client import RemoteClient
43
+ from .storage import LocalStorage
44
+ from .log import LogLevel, LogBuilder
45
+ from .params import ParametersBuilder
46
+
47
+ __version__ = "0.1.0"
8
48
 
9
49
  __all__ = [
10
50
  "Experiment",
11
- "ML_Logger",
51
+ "ml_dash_experiment",
52
+ "OperationMode",
53
+ "RemoteClient",
54
+ "LocalStorage",
12
55
  "LogLevel",
13
- "JobLogger",
56
+ "LogBuilder",
57
+ "ParametersBuilder",
14
58
  ]
ml_dash/client.py ADDED
@@ -0,0 +1,595 @@
1
+ """
2
+ Remote API client for ML-Dash server.
3
+ """
4
+
5
+ from typing import Optional, Dict, Any, List
6
+ import httpx
7
+
8
+
9
+ class RemoteClient:
10
+ """Client for communicating with ML-Dash server."""
11
+
12
+ def __init__(self, base_url: str, api_key: str):
13
+ """
14
+ Initialize remote client.
15
+
16
+ Args:
17
+ base_url: Base URL of ML-Dash server (e.g., "http://localhost:3000")
18
+ api_key: JWT token for authentication
19
+ """
20
+ self.base_url = base_url.rstrip("/")
21
+ self.api_key = api_key
22
+ self._client = httpx.Client(
23
+ base_url=self.base_url,
24
+ headers={
25
+ "Authorization": f"Bearer {api_key}",
26
+ # Note: Don't set Content-Type here as default
27
+ # It will be set per-request (json or multipart)
28
+ },
29
+ timeout=30.0,
30
+ )
31
+
32
+ def create_or_update_experiment(
33
+ self,
34
+ project: str,
35
+ name: str,
36
+ description: Optional[str] = None,
37
+ tags: Optional[List[str]] = None,
38
+ bindrs: Optional[List[str]] = None,
39
+ folder: Optional[str] = None,
40
+ write_protected: bool = False,
41
+ metadata: Optional[Dict[str, Any]] = None,
42
+ ) -> Dict[str, Any]:
43
+ """
44
+ Create or update an experiment.
45
+
46
+ Args:
47
+ project: Project name
48
+ name: Experiment name
49
+ description: Optional description
50
+ tags: Optional list of tags
51
+ bindrs: Optional list of bindrs
52
+ folder: Optional folder path
53
+ write_protected: If True, experiment becomes immutable
54
+ metadata: Optional metadata dict
55
+
56
+ Returns:
57
+ Response dict with experiment, project, folder, and namespace data
58
+
59
+ Raises:
60
+ httpx.HTTPStatusError: If request fails
61
+ """
62
+ payload = {
63
+ "name": name,
64
+ }
65
+
66
+ if description is not None:
67
+ payload["description"] = description
68
+ if tags is not None:
69
+ payload["tags"] = tags
70
+ if bindrs is not None:
71
+ payload["bindrs"] = bindrs
72
+ if folder is not None:
73
+ payload["folder"] = folder
74
+ if write_protected:
75
+ payload["writeProtected"] = write_protected
76
+ if metadata is not None:
77
+ payload["metadata"] = metadata
78
+
79
+ response = self._client.post(
80
+ f"/projects/{project}/experiments",
81
+ json=payload,
82
+ )
83
+ response.raise_for_status()
84
+ return response.json()
85
+
86
+ def update_experiment_status(
87
+ self,
88
+ experiment_id: str,
89
+ status: str,
90
+ ) -> Dict[str, Any]:
91
+ """
92
+ Update experiment status.
93
+
94
+ Args:
95
+ experiment_id: Experiment ID
96
+ status: Status value - "RUNNING" | "COMPLETED" | "FAILED" | "CANCELLED"
97
+
98
+ Returns:
99
+ Response dict with updated experiment data
100
+
101
+ Raises:
102
+ httpx.HTTPStatusError: If request fails
103
+ """
104
+ payload = {
105
+ "status": status,
106
+ }
107
+
108
+ response = self._client.patch(
109
+ f"/experiments/{experiment_id}/status",
110
+ json=payload,
111
+ )
112
+ response.raise_for_status()
113
+ return response.json()
114
+
115
+ def create_log_entries(
116
+ self,
117
+ experiment_id: str,
118
+ logs: List[Dict[str, Any]]
119
+ ) -> Dict[str, Any]:
120
+ """
121
+ Create log entries in batch.
122
+
123
+ Supports both single log and multiple logs via array.
124
+
125
+ Args:
126
+ experiment_id: Experiment ID (Snowflake ID)
127
+ logs: List of log entries, each with fields:
128
+ - timestamp: ISO 8601 string
129
+ - level: "info"|"warn"|"error"|"debug"|"fatal"
130
+ - message: Log message string
131
+ - metadata: Optional dict
132
+
133
+ Returns:
134
+ Response dict:
135
+ {
136
+ "created": 1,
137
+ "startSequence": 42,
138
+ "endSequence": 42,
139
+ "experimentId": "123456789"
140
+ }
141
+
142
+ Raises:
143
+ httpx.HTTPStatusError: If request fails
144
+ """
145
+ response = self._client.post(
146
+ f"/experiments/{experiment_id}/logs",
147
+ json={"logs": logs}
148
+ )
149
+ response.raise_for_status()
150
+ return response.json()
151
+
152
+ def set_parameters(
153
+ self,
154
+ experiment_id: str,
155
+ data: Dict[str, Any]
156
+ ) -> Dict[str, Any]:
157
+ """
158
+ Set/merge parameters for an experiment.
159
+
160
+ Always merges with existing parameters (upsert behavior).
161
+
162
+ Args:
163
+ experiment_id: Experiment ID (Snowflake ID)
164
+ data: Flattened parameter dict with dot notation
165
+ Example: {"model.lr": 0.001, "model.batch_size": 32}
166
+
167
+ Returns:
168
+ Response dict:
169
+ {
170
+ "id": "snowflake_id",
171
+ "experimentId": "experiment_id",
172
+ "data": {...},
173
+ "version": 2,
174
+ "createdAt": "...",
175
+ "updatedAt": "..."
176
+ }
177
+
178
+ Raises:
179
+ httpx.HTTPStatusError: If request fails
180
+ """
181
+ response = self._client.post(
182
+ f"/experiments/{experiment_id}/parameters",
183
+ json={"data": data}
184
+ )
185
+ response.raise_for_status()
186
+ return response.json()
187
+
188
+ def get_parameters(self, experiment_id: str) -> Dict[str, Any]:
189
+ """
190
+ Get parameters for an experiment.
191
+
192
+ Args:
193
+ experiment_id: Experiment ID (Snowflake ID)
194
+
195
+ Returns:
196
+ Flattened parameter dict with dot notation
197
+ Example: {"model.lr": 0.001, "model.batch_size": 32}
198
+
199
+ Raises:
200
+ httpx.HTTPStatusError: If request fails or parameters don't exist
201
+ """
202
+ response = self._client.get(f"/experiments/{experiment_id}/parameters")
203
+ response.raise_for_status()
204
+ result = response.json()
205
+ return result.get("data", {})
206
+
207
+ def upload_file(
208
+ self,
209
+ experiment_id: str,
210
+ file_path: str,
211
+ prefix: str,
212
+ filename: str,
213
+ description: Optional[str],
214
+ tags: Optional[List[str]],
215
+ metadata: Optional[Dict[str, Any]],
216
+ checksum: str,
217
+ content_type: str,
218
+ size_bytes: int
219
+ ) -> Dict[str, Any]:
220
+ """
221
+ Upload a file to an experiment.
222
+
223
+ Args:
224
+ experiment_id: Experiment ID (Snowflake ID)
225
+ file_path: Local file path
226
+ prefix: Logical path prefix
227
+ filename: Original filename
228
+ description: Optional description
229
+ tags: Optional tags
230
+ metadata: Optional metadata
231
+ checksum: SHA256 checksum
232
+ content_type: MIME type
233
+ size_bytes: File size in bytes
234
+
235
+ Returns:
236
+ File metadata dict
237
+
238
+ Raises:
239
+ httpx.HTTPStatusError: If request fails
240
+ """
241
+ # Prepare multipart form data
242
+ # Read file content first (httpx needs content, not file handle)
243
+ with open(file_path, "rb") as f:
244
+ file_content = f.read()
245
+
246
+ files = {"file": (filename, file_content, content_type)}
247
+ data = {
248
+ "prefix": prefix,
249
+ "checksum": checksum,
250
+ "sizeBytes": str(size_bytes),
251
+ }
252
+ if description:
253
+ data["description"] = description
254
+ if tags:
255
+ data["tags"] = ",".join(tags)
256
+ if metadata:
257
+ import json
258
+ data["metadata"] = json.dumps(metadata)
259
+
260
+ # httpx will automatically set multipart/form-data content-type
261
+ response = self._client.post(
262
+ f"/experiments/{experiment_id}/files",
263
+ files=files,
264
+ data=data
265
+ )
266
+
267
+ response.raise_for_status()
268
+ return response.json()
269
+
270
+ def list_files(
271
+ self,
272
+ experiment_id: str,
273
+ prefix: Optional[str] = None,
274
+ tags: Optional[List[str]] = None
275
+ ) -> List[Dict[str, Any]]:
276
+ """
277
+ List files in an experiment.
278
+
279
+ Args:
280
+ experiment_id: Experiment ID (Snowflake ID)
281
+ prefix: Optional prefix filter
282
+ tags: Optional tags filter
283
+
284
+ Returns:
285
+ List of file metadata dicts
286
+
287
+ Raises:
288
+ httpx.HTTPStatusError: If request fails
289
+ """
290
+ params = {}
291
+ if prefix:
292
+ params["prefix"] = prefix
293
+ if tags:
294
+ params["tags"] = ",".join(tags)
295
+
296
+ response = self._client.get(
297
+ f"/experiments/{experiment_id}/files",
298
+ params=params
299
+ )
300
+ response.raise_for_status()
301
+ result = response.json()
302
+ return result.get("files", [])
303
+
304
+ def get_file(self, experiment_id: str, file_id: str) -> Dict[str, Any]:
305
+ """
306
+ Get file metadata.
307
+
308
+ Args:
309
+ experiment_id: Experiment ID (Snowflake ID)
310
+ file_id: File ID (Snowflake ID)
311
+
312
+ Returns:
313
+ File metadata dict
314
+
315
+ Raises:
316
+ httpx.HTTPStatusError: If request fails
317
+ """
318
+ response = self._client.get(f"/experiments/{experiment_id}/files/{file_id}")
319
+ response.raise_for_status()
320
+ return response.json()
321
+
322
+ def download_file(
323
+ self,
324
+ experiment_id: str,
325
+ file_id: str,
326
+ dest_path: Optional[str] = None
327
+ ) -> str:
328
+ """
329
+ Download a file from a experiment.
330
+
331
+ Args:
332
+ experiment_id: Experiment ID (Snowflake ID)
333
+ file_id: File ID (Snowflake ID)
334
+ dest_path: Optional destination path (defaults to original filename)
335
+
336
+ Returns:
337
+ Path to downloaded file
338
+
339
+ Raises:
340
+ httpx.HTTPStatusError: If request fails
341
+ ValueError: If checksum verification fails
342
+ """
343
+ # Get file metadata first to get filename and checksum
344
+ file_metadata = self.get_file(experiment_id, file_id)
345
+ filename = file_metadata["filename"]
346
+ expected_checksum = file_metadata["checksum"]
347
+
348
+ # Determine destination path
349
+ if dest_path is None:
350
+ dest_path = filename
351
+
352
+ # Download file
353
+ response = self._client.get(
354
+ f"/experiments/{experiment_id}/files/{file_id}/download"
355
+ )
356
+ response.raise_for_status()
357
+
358
+ # Write to file
359
+ with open(dest_path, "wb") as f:
360
+ f.write(response.content)
361
+
362
+ # Verify checksum
363
+ from .files import verify_checksum
364
+ if not verify_checksum(dest_path, expected_checksum):
365
+ # Delete corrupted file
366
+ import os
367
+ os.remove(dest_path)
368
+ raise ValueError(f"Checksum verification failed for file {file_id}")
369
+
370
+ return dest_path
371
+
372
+ def delete_file(self, experiment_id: str, file_id: str) -> Dict[str, Any]:
373
+ """
374
+ Delete a file (soft delete).
375
+
376
+ Args:
377
+ experiment_id: Experiment ID (Snowflake ID)
378
+ file_id: File ID (Snowflake ID)
379
+
380
+ Returns:
381
+ Dict with id and deletedAt
382
+
383
+ Raises:
384
+ httpx.HTTPStatusError: If request fails
385
+ """
386
+ response = self._client.delete(f"/experiments/{experiment_id}/files/{file_id}")
387
+ response.raise_for_status()
388
+ return response.json()
389
+
390
+ def update_file(
391
+ self,
392
+ experiment_id: str,
393
+ file_id: str,
394
+ description: Optional[str] = None,
395
+ tags: Optional[List[str]] = None,
396
+ metadata: Optional[Dict[str, Any]] = None
397
+ ) -> Dict[str, Any]:
398
+ """
399
+ Update file metadata.
400
+
401
+ Args:
402
+ experiment_id: Experiment ID (Snowflake ID)
403
+ file_id: File ID (Snowflake ID)
404
+ description: Optional description
405
+ tags: Optional tags
406
+ metadata: Optional metadata
407
+
408
+ Returns:
409
+ Updated file metadata dict
410
+
411
+ Raises:
412
+ httpx.HTTPStatusError: If request fails
413
+ """
414
+ payload = {}
415
+ if description is not None:
416
+ payload["description"] = description
417
+ if tags is not None:
418
+ payload["tags"] = tags
419
+ if metadata is not None:
420
+ payload["metadata"] = metadata
421
+
422
+ response = self._client.patch(
423
+ f"/experiments/{experiment_id}/files/{file_id}",
424
+ json=payload
425
+ )
426
+ response.raise_for_status()
427
+ return response.json()
428
+
429
+ def append_to_metric(
430
+ self,
431
+ experiment_id: str,
432
+ metric_name: str,
433
+ data: Dict[str, Any],
434
+ description: Optional[str] = None,
435
+ tags: Optional[List[str]] = None,
436
+ metadata: Optional[Dict[str, Any]] = None
437
+ ) -> Dict[str, Any]:
438
+ """
439
+ Append a single data point to a metric.
440
+
441
+ Args:
442
+ experiment_id: Experiment ID (Snowflake ID)
443
+ metric_name: Metric name (unique within experiment)
444
+ data: Data point (flexible schema)
445
+ description: Optional metric description
446
+ tags: Optional tags
447
+ metadata: Optional metadata
448
+
449
+ Returns:
450
+ Dict with metricId, index, bufferedDataPoints, chunkSize
451
+
452
+ Raises:
453
+ httpx.HTTPStatusError: If request fails
454
+ """
455
+ payload = {"data": data}
456
+ if description:
457
+ payload["description"] = description
458
+ if tags:
459
+ payload["tags"] = tags
460
+ if metadata:
461
+ payload["metadata"] = metadata
462
+
463
+ response = self._client.post(
464
+ f"/experiments/{experiment_id}/metrics/{metric_name}/append",
465
+ json=payload
466
+ )
467
+ response.raise_for_status()
468
+ return response.json()
469
+
470
+ def append_batch_to_metric(
471
+ self,
472
+ experiment_id: str,
473
+ metric_name: str,
474
+ data_points: List[Dict[str, Any]],
475
+ description: Optional[str] = None,
476
+ tags: Optional[List[str]] = None,
477
+ metadata: Optional[Dict[str, Any]] = None
478
+ ) -> Dict[str, Any]:
479
+ """
480
+ Append multiple data points to a metric in batch.
481
+
482
+ Args:
483
+ experiment_id: Experiment ID (Snowflake ID)
484
+ metric_name: Metric name (unique within experiment)
485
+ data_points: List of data points
486
+ description: Optional metric description
487
+ tags: Optional tags
488
+ metadata: Optional metadata
489
+
490
+ Returns:
491
+ Dict with metricId, startIndex, endIndex, count, bufferedDataPoints, chunkSize
492
+
493
+ Raises:
494
+ httpx.HTTPStatusError: If request fails
495
+ """
496
+ payload = {"dataPoints": data_points}
497
+ if description:
498
+ payload["description"] = description
499
+ if tags:
500
+ payload["tags"] = tags
501
+ if metadata:
502
+ payload["metadata"] = metadata
503
+
504
+ response = self._client.post(
505
+ f"/experiments/{experiment_id}/metrics/{metric_name}/append-batch",
506
+ json=payload
507
+ )
508
+ response.raise_for_status()
509
+ return response.json()
510
+
511
+ def read_metric_data(
512
+ self,
513
+ experiment_id: str,
514
+ metric_name: str,
515
+ start_index: int = 0,
516
+ limit: int = 1000
517
+ ) -> Dict[str, Any]:
518
+ """
519
+ Read data points from a metric.
520
+
521
+ Args:
522
+ experiment_id: Experiment ID (Snowflake ID)
523
+ metric_name: Metric name
524
+ start_index: Starting index (default 0)
525
+ limit: Max points to read (default 1000, max 10000)
526
+
527
+ Returns:
528
+ Dict with data, startIndex, endIndex, total, hasMore
529
+
530
+ Raises:
531
+ httpx.HTTPStatusError: If request fails
532
+ """
533
+ response = self._client.get(
534
+ f"/experiments/{experiment_id}/metrics/{metric_name}/data",
535
+ params={"startIndex": start_index, "limit": limit}
536
+ )
537
+ response.raise_for_status()
538
+ return response.json()
539
+
540
+ def get_metric_stats(
541
+ self,
542
+ experiment_id: str,
543
+ metric_name: str
544
+ ) -> Dict[str, Any]:
545
+ """
546
+ Get metric statistics and metadata.
547
+
548
+ Args:
549
+ experiment_id: Experiment ID (Snowflake ID)
550
+ metric_name: Metric name
551
+
552
+ Returns:
553
+ Dict with metric stats (totalDataPoints, bufferedDataPoints, etc.)
554
+
555
+ Raises:
556
+ httpx.HTTPStatusError: If request fails
557
+ """
558
+ response = self._client.get(
559
+ f"/experiments/{experiment_id}/metrics/{metric_name}/stats"
560
+ )
561
+ response.raise_for_status()
562
+ return response.json()
563
+
564
+ def list_metrics(
565
+ self,
566
+ experiment_id: str
567
+ ) -> List[Dict[str, Any]]:
568
+ """
569
+ List all metrics in an experiment.
570
+
571
+ Args:
572
+ experiment_id: Experiment ID (Snowflake ID)
573
+
574
+ Returns:
575
+ List of metric summaries
576
+
577
+ Raises:
578
+ httpx.HTTPStatusError: If request fails
579
+ """
580
+ response = self._client.get(f"/experiments/{experiment_id}/metrics")
581
+ response.raise_for_status()
582
+ return response.json()["metrics"]
583
+
584
+ def close(self):
585
+ """Close the HTTP client."""
586
+ self._client.close()
587
+
588
+ def __enter__(self):
589
+ """Context manager entry."""
590
+ return self
591
+
592
+ def __exit__(self, exc_type, exc_val, exc_tb):
593
+ """Context manager exit."""
594
+ self.close()
595
+ return False