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/experiment.py ADDED
@@ -0,0 +1,939 @@
1
+ """
2
+ Experiment class for ML-Dash SDK.
3
+
4
+ Supports three usage styles:
5
+ 1. Decorator: @ml_dash_experiment(...)
6
+ 2. Context manager: with Experiment(...) as exp:
7
+ 3. Direct instantiation: exp = Experiment(...)
8
+ """
9
+
10
+ from typing import Optional, Dict, Any, List, Callable
11
+ from enum import Enum
12
+ import functools
13
+ from pathlib import Path
14
+ from datetime import datetime
15
+
16
+ from .client import RemoteClient
17
+ from .storage import LocalStorage
18
+ from .log import LogLevel, LogBuilder
19
+ from .params import ParametersBuilder
20
+ from .files import FileBuilder
21
+
22
+
23
+ class OperationMode(Enum):
24
+ """Operation mode for the experiment."""
25
+ LOCAL = "local"
26
+ REMOTE = "remote"
27
+ HYBRID = "hybrid" # Future: sync local to remote
28
+
29
+
30
+ class Experiment:
31
+ """
32
+ ML-Dash experiment for metricing experiments.
33
+
34
+ Usage examples:
35
+
36
+ # Remote mode
37
+ experiment = Experiment(
38
+ name="my-experiment",
39
+ project="my-project",
40
+ remote="http://localhost:3000",
41
+ api_key="your-jwt-token"
42
+ )
43
+
44
+ # Local mode
45
+ experiment = Experiment(
46
+ name="my-experiment",
47
+ project="my-project",
48
+ local_path=".ml-dash"
49
+ )
50
+
51
+ # Context manager
52
+ with Experiment(...) as exp:
53
+ exp.log(...)
54
+
55
+ # Decorator
56
+ @ml_dash_experiment(name="exp", project="ws", remote="...")
57
+ def train():
58
+ ...
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ name: str,
64
+ project: str,
65
+ *,
66
+ description: Optional[str] = None,
67
+ tags: Optional[List[str]] = None,
68
+ bindrs: Optional[List[str]] = None,
69
+ folder: Optional[str] = None,
70
+ write_protected: bool = False,
71
+ metadata: Optional[Dict[str, Any]] = None,
72
+ # Mode configuration
73
+ remote: Optional[str] = None,
74
+ api_key: Optional[str] = None,
75
+ user_name: Optional[str] = None,
76
+ local_path: Optional[str] = None,
77
+ ):
78
+ """
79
+ Initialize an ML-Dash experiment.
80
+
81
+ Args:
82
+ name: Experiment name (unique within project)
83
+ project: Project name
84
+ description: Optional experiment description
85
+ tags: Optional list of tags
86
+ bindrs: Optional list of bindrs
87
+ folder: Optional folder path (e.g., "/experiments/baseline")
88
+ write_protected: If True, experiment becomes immutable after creation
89
+ metadata: Optional metadata dict
90
+ remote: Remote API URL (e.g., "http://localhost:3000")
91
+ api_key: JWT token for authentication (if not provided, will be generated from user_name)
92
+ user_name: Username for authentication (generates API key if api_key not provided)
93
+ local_path: Local storage root path (for local mode)
94
+ """
95
+ self.name = name
96
+ self.project = project
97
+ self.description = description
98
+ self.tags = tags
99
+ self.bindrs = bindrs
100
+ self.folder = folder
101
+ self.write_protected = write_protected
102
+ self.metadata = metadata
103
+
104
+ # Generate API key from username if not provided
105
+ if remote and not api_key and user_name:
106
+ api_key = self._generate_api_key_from_username(user_name)
107
+
108
+ # Determine operation mode
109
+ if remote and local_path:
110
+ self.mode = OperationMode.HYBRID
111
+ elif remote:
112
+ self.mode = OperationMode.REMOTE
113
+ elif local_path:
114
+ self.mode = OperationMode.LOCAL
115
+ else:
116
+ raise ValueError(
117
+ "Must specify either 'remote' (with api_key/user_name) or 'local_path'"
118
+ )
119
+
120
+ # Initialize backend
121
+ self._client: Optional[RemoteClient] = None
122
+ self._storage: Optional[LocalStorage] = None
123
+ self._experiment_id: Optional[str] = None
124
+ self._experiment_data: Optional[Dict[str, Any]] = None
125
+ self._is_open = False
126
+
127
+ if self.mode in (OperationMode.REMOTE, OperationMode.HYBRID):
128
+ if not api_key:
129
+ raise ValueError("Either api_key or user_name is required for remote mode")
130
+ self._client = RemoteClient(base_url=remote, api_key=api_key)
131
+
132
+ if self.mode in (OperationMode.LOCAL, OperationMode.HYBRID):
133
+ if not local_path:
134
+ raise ValueError("local_path is required for local mode")
135
+ self._storage = LocalStorage(root_path=Path(local_path))
136
+
137
+ @staticmethod
138
+ def _generate_api_key_from_username(user_name: str) -> str:
139
+ """
140
+ Generate a deterministic API key (JWT) from username.
141
+
142
+ This is a temporary solution until proper user authentication is implemented.
143
+ Generates a unique user ID from the username and creates a JWT token.
144
+
145
+ Args:
146
+ user_name: Username to generate API key from
147
+
148
+ Returns:
149
+ JWT token string
150
+ """
151
+ import hashlib
152
+ import time
153
+ import jwt
154
+
155
+ # Generate deterministic user ID from username (first 10 digits of SHA256 hash)
156
+ user_id = str(int(hashlib.sha256(user_name.encode()).hexdigest()[:16], 16))[:10]
157
+
158
+ # JWT payload
159
+ payload = {
160
+ "userId": user_id,
161
+ "userName": user_name,
162
+ "iat": int(time.time()),
163
+ "exp": int(time.time()) + (30 * 24 * 60 * 60) # 30 days expiration
164
+ }
165
+
166
+ # Secret key for signing (should match server's JWT_SECRET)
167
+ secret = "your-secret-key-change-this-in-production"
168
+
169
+ # Generate JWT
170
+ token = jwt.encode(payload, secret, algorithm="HS256")
171
+
172
+ return token
173
+
174
+ def open(self) -> "Experiment":
175
+ """
176
+ Open the experiment (create or update on server/filesystem).
177
+
178
+ Returns:
179
+ self for chaining
180
+ """
181
+ if self._is_open:
182
+ return self
183
+
184
+ if self._client:
185
+ # Remote mode: create/update experiment via API
186
+ response = self._client.create_or_update_experiment(
187
+ project=self.project,
188
+ name=self.name,
189
+ description=self.description,
190
+ tags=self.tags,
191
+ bindrs=self.bindrs,
192
+ folder=self.folder,
193
+ write_protected=self.write_protected,
194
+ metadata=self.metadata,
195
+ )
196
+ self._experiment_data = response
197
+ self._experiment_id = response["experiment"]["id"]
198
+
199
+ if self._storage:
200
+ # Local mode: create experiment directory structure
201
+ self._storage.create_experiment(
202
+ project=self.project,
203
+ name=self.name,
204
+ description=self.description,
205
+ tags=self.tags,
206
+ bindrs=self.bindrs,
207
+ folder=self.folder,
208
+ metadata=self.metadata,
209
+ )
210
+
211
+ self._is_open = True
212
+ return self
213
+
214
+ def close(self, status: str = "COMPLETED"):
215
+ """
216
+ Close the experiment and update status.
217
+
218
+ Args:
219
+ status: Status to set - "COMPLETED" (default), "FAILED", or "CANCELLED"
220
+ """
221
+ if not self._is_open:
222
+ return
223
+
224
+ # Flush any pending writes
225
+ if self._storage:
226
+ self._storage.flush()
227
+
228
+ # Update experiment status in remote mode
229
+ if self._client and self._experiment_id:
230
+ try:
231
+ self._client.update_experiment_status(
232
+ experiment_id=self._experiment_id,
233
+ status=status
234
+ )
235
+ except Exception as e:
236
+ # Log error but don't fail the close operation
237
+ print(f"Warning: Failed to update experiment status: {e}")
238
+
239
+ self._is_open = False
240
+
241
+ def __enter__(self) -> "Experiment":
242
+ """Context manager entry."""
243
+ return self.open()
244
+
245
+ def __exit__(self, exc_type, exc_val, exc_tb):
246
+ """Context manager exit. Sets status to FAILED if exception occurred."""
247
+ # Determine status based on whether an exception occurred
248
+ status = "FAILED" if exc_type is not None else "COMPLETED"
249
+ self.close(status=status)
250
+ return False
251
+
252
+ def log(
253
+ self,
254
+ message: Optional[str] = None,
255
+ level: Optional[str] = None,
256
+ metadata: Optional[Dict[str, Any]] = None,
257
+ **extra_metadata
258
+ ) -> Optional[LogBuilder]:
259
+ """
260
+ Create a log entry or return a LogBuilder for fluent API.
261
+
262
+ This method supports two styles:
263
+
264
+ 1. Fluent style (no message provided):
265
+ Returns a LogBuilder that allows chaining with level methods.
266
+
267
+ Examples:
268
+ experiment.log(metadata={"epoch": 1}).info("Training started")
269
+ experiment.log().error("Failed", error_code=500)
270
+
271
+ 2. Traditional style (message provided):
272
+ Writes the log immediately and returns None.
273
+
274
+ Examples:
275
+ experiment.log("Training started", level="info", epoch=1)
276
+ experiment.log("Training started") # Defaults to "info"
277
+
278
+ Args:
279
+ message: Optional log message (for traditional style)
280
+ level: Optional log level (for traditional style, defaults to "info")
281
+ metadata: Optional metadata dict
282
+ **extra_metadata: Additional metadata as keyword arguments
283
+
284
+ Returns:
285
+ LogBuilder if no message provided (fluent mode)
286
+ None if log was written directly (traditional mode)
287
+
288
+ Raises:
289
+ RuntimeError: If experiment is not open
290
+ ValueError: If log level is invalid
291
+ """
292
+ if not self._is_open:
293
+ raise RuntimeError("Experiment not open. Use experiment.open() or context manager.")
294
+
295
+ # Fluent mode: return LogBuilder
296
+ if message is None:
297
+ combined_metadata = {**(metadata or {}), **extra_metadata}
298
+ return LogBuilder(self, combined_metadata if combined_metadata else None)
299
+
300
+ # Traditional mode: write immediately
301
+ level = level or LogLevel.INFO.value # Default to "info"
302
+ level = LogLevel.validate(level) # Validate level
303
+
304
+ combined_metadata = {**(metadata or {}), **extra_metadata}
305
+ self._write_log(
306
+ message=message,
307
+ level=level,
308
+ metadata=combined_metadata if combined_metadata else None,
309
+ timestamp=None
310
+ )
311
+ return None
312
+
313
+ def _write_log(
314
+ self,
315
+ message: str,
316
+ level: str,
317
+ metadata: Optional[Dict[str, Any]],
318
+ timestamp: Optional[datetime]
319
+ ) -> None:
320
+ """
321
+ Internal method to write a log entry immediately.
322
+ No buffering - writes directly to storage/remote.
323
+
324
+ Args:
325
+ message: Log message
326
+ level: Log level (already validated)
327
+ metadata: Optional metadata dict
328
+ timestamp: Optional custom timestamp (defaults to now)
329
+ """
330
+ log_entry = {
331
+ "timestamp": (timestamp or datetime.utcnow()).isoformat() + "Z",
332
+ "level": level,
333
+ "message": message,
334
+ }
335
+
336
+ if metadata:
337
+ log_entry["metadata"] = metadata
338
+
339
+ # Write immediately (no buffering)
340
+ if self._client:
341
+ # Remote mode: send to API (wrapped in array for batch API)
342
+ self._client.create_log_entries(
343
+ experiment_id=self._experiment_id,
344
+ logs=[log_entry] # Single log in array
345
+ )
346
+
347
+ if self._storage:
348
+ # Local mode: write to file immediately
349
+ self._storage.write_log(
350
+ project=self.project,
351
+ experiment=self.name,
352
+ message=log_entry["message"],
353
+ level=log_entry["level"],
354
+ metadata=log_entry.get("metadata"),
355
+ timestamp=log_entry["timestamp"]
356
+ )
357
+
358
+ def file(self, **kwargs) -> FileBuilder:
359
+ """
360
+ Get a FileBuilder for fluent file operations.
361
+
362
+ Returns:
363
+ FileBuilder instance for chaining
364
+
365
+ Raises:
366
+ RuntimeError: If experiment is not open
367
+
368
+ Examples:
369
+ # Upload file
370
+ experiment.file(file_path="./model.pt", prefix="/models").save()
371
+
372
+ # List files
373
+ files = experiment.file().list()
374
+ files = experiment.file(prefix="/models").list()
375
+
376
+ # Download file
377
+ experiment.file(file_id="123").download()
378
+
379
+ # Delete file
380
+ experiment.file(file_id="123").delete()
381
+ """
382
+ if not self._is_open:
383
+ raise RuntimeError("Experiment not open. Use experiment.open() or context manager.")
384
+
385
+ return FileBuilder(self, **kwargs)
386
+
387
+ def _upload_file(
388
+ self,
389
+ file_path: str,
390
+ prefix: str,
391
+ filename: str,
392
+ description: Optional[str],
393
+ tags: Optional[List[str]],
394
+ metadata: Optional[Dict[str, Any]],
395
+ checksum: str,
396
+ content_type: str,
397
+ size_bytes: int
398
+ ) -> Dict[str, Any]:
399
+ """
400
+ Internal method to upload a file.
401
+
402
+ Args:
403
+ file_path: Local file path
404
+ prefix: Logical path prefix
405
+ filename: Original filename
406
+ description: Optional description
407
+ tags: Optional tags
408
+ metadata: Optional metadata
409
+ checksum: SHA256 checksum
410
+ content_type: MIME type
411
+ size_bytes: File size in bytes
412
+
413
+ Returns:
414
+ File metadata dict
415
+ """
416
+ result = None
417
+
418
+ if self._client:
419
+ # Remote mode: upload to API
420
+ result = self._client.upload_file(
421
+ experiment_id=self._experiment_id,
422
+ file_path=file_path,
423
+ prefix=prefix,
424
+ filename=filename,
425
+ description=description,
426
+ tags=tags,
427
+ metadata=metadata,
428
+ checksum=checksum,
429
+ content_type=content_type,
430
+ size_bytes=size_bytes
431
+ )
432
+
433
+ if self._storage:
434
+ # Local mode: copy to local storage
435
+ result = self._storage.write_file(
436
+ project=self.project,
437
+ experiment=self.name,
438
+ file_path=file_path,
439
+ prefix=prefix,
440
+ filename=filename,
441
+ description=description,
442
+ tags=tags,
443
+ metadata=metadata,
444
+ checksum=checksum,
445
+ content_type=content_type,
446
+ size_bytes=size_bytes
447
+ )
448
+
449
+ return result
450
+
451
+ def _list_files(
452
+ self,
453
+ prefix: Optional[str] = None,
454
+ tags: Optional[List[str]] = None
455
+ ) -> List[Dict[str, Any]]:
456
+ """
457
+ Internal method to list files.
458
+
459
+ Args:
460
+ prefix: Optional prefix filter
461
+ tags: Optional tags filter
462
+
463
+ Returns:
464
+ List of file metadata dicts
465
+ """
466
+ files = []
467
+
468
+ if self._client:
469
+ # Remote mode: fetch from API
470
+ files = self._client.list_files(
471
+ experiment_id=self._experiment_id,
472
+ prefix=prefix,
473
+ tags=tags
474
+ )
475
+
476
+ if self._storage:
477
+ # Local mode: read from metadata file
478
+ files = self._storage.list_files(
479
+ project=self.project,
480
+ experiment=self.name,
481
+ prefix=prefix,
482
+ tags=tags
483
+ )
484
+
485
+ return files
486
+
487
+ def _download_file(
488
+ self,
489
+ file_id: str,
490
+ dest_path: Optional[str] = None
491
+ ) -> str:
492
+ """
493
+ Internal method to download a file.
494
+
495
+ Args:
496
+ file_id: File ID
497
+ dest_path: Optional destination path (defaults to original filename)
498
+
499
+ Returns:
500
+ Path to downloaded file
501
+ """
502
+ if self._client:
503
+ # Remote mode: download from API
504
+ return self._client.download_file(
505
+ experiment_id=self._experiment_id,
506
+ file_id=file_id,
507
+ dest_path=dest_path
508
+ )
509
+
510
+ if self._storage:
511
+ # Local mode: copy from local storage
512
+ return self._storage.read_file(
513
+ project=self.project,
514
+ experiment=self.name,
515
+ file_id=file_id,
516
+ dest_path=dest_path
517
+ )
518
+
519
+ raise RuntimeError("No client or storage configured")
520
+
521
+ def _delete_file(self, file_id: str) -> Dict[str, Any]:
522
+ """
523
+ Internal method to delete a file.
524
+
525
+ Args:
526
+ file_id: File ID
527
+
528
+ Returns:
529
+ Dict with id and deletedAt
530
+ """
531
+ result = None
532
+
533
+ if self._client:
534
+ # Remote mode: delete via API
535
+ result = self._client.delete_file(
536
+ experiment_id=self._experiment_id,
537
+ file_id=file_id
538
+ )
539
+
540
+ if self._storage:
541
+ # Local mode: soft delete in metadata
542
+ result = self._storage.delete_file(
543
+ project=self.project,
544
+ experiment=self.name,
545
+ file_id=file_id
546
+ )
547
+
548
+ return result
549
+
550
+ def _update_file(
551
+ self,
552
+ file_id: str,
553
+ description: Optional[str],
554
+ tags: Optional[List[str]],
555
+ metadata: Optional[Dict[str, Any]]
556
+ ) -> Dict[str, Any]:
557
+ """
558
+ Internal method to update file metadata.
559
+
560
+ Args:
561
+ file_id: File ID
562
+ description: Optional description
563
+ tags: Optional tags
564
+ metadata: Optional metadata
565
+
566
+ Returns:
567
+ Updated file metadata dict
568
+ """
569
+ result = None
570
+
571
+ if self._client:
572
+ # Remote mode: update via API
573
+ result = self._client.update_file(
574
+ experiment_id=self._experiment_id,
575
+ file_id=file_id,
576
+ description=description,
577
+ tags=tags,
578
+ metadata=metadata
579
+ )
580
+
581
+ if self._storage:
582
+ # Local mode: update in metadata file
583
+ result = self._storage.update_file_metadata(
584
+ project=self.project,
585
+ experiment=self.name,
586
+ file_id=file_id,
587
+ description=description,
588
+ tags=tags,
589
+ metadata=metadata
590
+ )
591
+
592
+ return result
593
+
594
+ def parameters(self) -> ParametersBuilder:
595
+ """
596
+ Get a ParametersBuilder for fluent parameter operations.
597
+
598
+ Returns:
599
+ ParametersBuilder instance for chaining
600
+
601
+ Raises:
602
+ RuntimeError: If experiment is not open
603
+
604
+ Examples:
605
+ # Set parameters
606
+ experiment.parameters().set(
607
+ model={"lr": 0.001, "batch_size": 32},
608
+ optimizer="adam"
609
+ )
610
+
611
+ # Get parameters
612
+ params = experiment.parameters().get() # Flattened
613
+ params = experiment.parameters().get(flatten=False) # Nested
614
+ """
615
+ if not self._is_open:
616
+ raise RuntimeError("Experiment not open. Use experiment.open() or context manager.")
617
+
618
+ return ParametersBuilder(self)
619
+
620
+ def _write_params(self, flattened_params: Dict[str, Any]) -> None:
621
+ """
622
+ Internal method to write/merge parameters.
623
+
624
+ Args:
625
+ flattened_params: Already-flattened parameter dict with dot notation
626
+ """
627
+ if self._client:
628
+ # Remote mode: send to API
629
+ self._client.set_parameters(
630
+ experiment_id=self._experiment_id,
631
+ data=flattened_params
632
+ )
633
+
634
+ if self._storage:
635
+ # Local mode: write to file
636
+ self._storage.write_parameters(
637
+ project=self.project,
638
+ experiment=self.name,
639
+ data=flattened_params
640
+ )
641
+
642
+ def _read_params(self) -> Optional[Dict[str, Any]]:
643
+ """
644
+ Internal method to read parameters.
645
+
646
+ Returns:
647
+ Flattened parameters dict, or None if no parameters exist
648
+ """
649
+ params = None
650
+
651
+ if self._client:
652
+ # Remote mode: fetch from API
653
+ try:
654
+ params = self._client.get_parameters(experiment_id=self._experiment_id)
655
+ except Exception:
656
+ # Parameters don't exist yet
657
+ params = None
658
+
659
+ if self._storage:
660
+ # Local mode: read from file
661
+ params = self._storage.read_parameters(
662
+ project=self.project,
663
+ experiment=self.name
664
+ )
665
+
666
+ return params
667
+
668
+ def metric(self, name: str, description: Optional[str] = None,
669
+ tags: Optional[List[str]] = None, metadata: Optional[Dict[str, Any]] = None) -> 'MetricBuilder':
670
+ """
671
+ Get a MetricBuilder for fluent metric operations.
672
+
673
+ Args:
674
+ name: Metric name (unique within experiment)
675
+ description: Optional metric description
676
+ tags: Optional tags for categorization
677
+ metadata: Optional structured metadata
678
+
679
+ Returns:
680
+ MetricBuilder instance for chaining
681
+
682
+ Raises:
683
+ RuntimeError: If experiment is not open
684
+
685
+ Examples:
686
+ # Append single data point
687
+ experiment.metric(name="train_loss").append(value=0.5, step=100)
688
+
689
+ # Append batch
690
+ experiment.metric(name="metrics").append_batch([
691
+ {"loss": 0.5, "acc": 0.8, "step": 1},
692
+ {"loss": 0.4, "acc": 0.85, "step": 2}
693
+ ])
694
+
695
+ # Read data
696
+ data = experiment.metric(name="train_loss").read(start_index=0, limit=100)
697
+
698
+ # Get statistics
699
+ stats = experiment.metric(name="train_loss").stats()
700
+ """
701
+ from .metric import MetricBuilder
702
+
703
+ if not self._is_open:
704
+ raise RuntimeError(
705
+ "Cannot use metric on closed experiment. "
706
+ "Use 'with Experiment(...) as experiment:' or call experiment.open() first."
707
+ )
708
+
709
+ return MetricBuilder(self, name, description, tags, metadata)
710
+
711
+ def _append_to_metric(
712
+ self,
713
+ name: str,
714
+ data: Dict[str, Any],
715
+ description: Optional[str],
716
+ tags: Optional[List[str]],
717
+ metadata: Optional[Dict[str, Any]]
718
+ ) -> Dict[str, Any]:
719
+ """
720
+ Internal method to append a single data point to a metric.
721
+
722
+ Args:
723
+ name: Metric name
724
+ data: Data point (flexible schema)
725
+ description: Optional metric description
726
+ tags: Optional tags
727
+ metadata: Optional metadata
728
+
729
+ Returns:
730
+ Dict with metricId, index, bufferedDataPoints, chunkSize
731
+ """
732
+ result = None
733
+
734
+ if self._client:
735
+ # Remote mode: append via API
736
+ result = self._client.append_to_metric(
737
+ experiment_id=self._experiment_id,
738
+ metric_name=name,
739
+ data=data,
740
+ description=description,
741
+ tags=tags,
742
+ metadata=metadata
743
+ )
744
+
745
+ if self._storage:
746
+ # Local mode: append to local storage
747
+ result = self._storage.append_to_metric(
748
+ project=self.project,
749
+ experiment=self.name,
750
+ metric_name=name,
751
+ data=data,
752
+ description=description,
753
+ tags=tags,
754
+ metadata=metadata
755
+ )
756
+
757
+ return result
758
+
759
+ def _append_batch_to_metric(
760
+ self,
761
+ name: str,
762
+ data_points: List[Dict[str, Any]],
763
+ description: Optional[str],
764
+ tags: Optional[List[str]],
765
+ metadata: Optional[Dict[str, Any]]
766
+ ) -> Dict[str, Any]:
767
+ """
768
+ Internal method to append multiple data points to a metric.
769
+
770
+ Args:
771
+ name: Metric name
772
+ data_points: List of data points
773
+ description: Optional metric description
774
+ tags: Optional tags
775
+ metadata: Optional metadata
776
+
777
+ Returns:
778
+ Dict with metricId, startIndex, endIndex, count
779
+ """
780
+ result = None
781
+
782
+ if self._client:
783
+ # Remote mode: append batch via API
784
+ result = self._client.append_batch_to_metric(
785
+ experiment_id=self._experiment_id,
786
+ metric_name=name,
787
+ data_points=data_points,
788
+ description=description,
789
+ tags=tags,
790
+ metadata=metadata
791
+ )
792
+
793
+ if self._storage:
794
+ # Local mode: append batch to local storage
795
+ result = self._storage.append_batch_to_metric(
796
+ project=self.project,
797
+ experiment=self.name,
798
+ metric_name=name,
799
+ data_points=data_points,
800
+ description=description,
801
+ tags=tags,
802
+ metadata=metadata
803
+ )
804
+
805
+ return result
806
+
807
+ def _read_metric_data(
808
+ self,
809
+ name: str,
810
+ start_index: int,
811
+ limit: int
812
+ ) -> Dict[str, Any]:
813
+ """
814
+ Internal method to read data points from a metric.
815
+
816
+ Args:
817
+ name: Metric name
818
+ start_index: Starting index
819
+ limit: Max points to read
820
+
821
+ Returns:
822
+ Dict with data, startIndex, endIndex, total, hasMore
823
+ """
824
+ result = None
825
+
826
+ if self._client:
827
+ # Remote mode: read via API
828
+ result = self._client.read_metric_data(
829
+ experiment_id=self._experiment_id,
830
+ metric_name=name,
831
+ start_index=start_index,
832
+ limit=limit
833
+ )
834
+
835
+ if self._storage:
836
+ # Local mode: read from local storage
837
+ result = self._storage.read_metric_data(
838
+ project=self.project,
839
+ experiment=self.name,
840
+ metric_name=name,
841
+ start_index=start_index,
842
+ limit=limit
843
+ )
844
+
845
+ return result
846
+
847
+ def _get_metric_stats(self, name: str) -> Dict[str, Any]:
848
+ """
849
+ Internal method to get metric statistics.
850
+
851
+ Args:
852
+ name: Metric name
853
+
854
+ Returns:
855
+ Dict with metric stats
856
+ """
857
+ result = None
858
+
859
+ if self._client:
860
+ # Remote mode: get stats via API
861
+ result = self._client.get_metric_stats(
862
+ experiment_id=self._experiment_id,
863
+ metric_name=name
864
+ )
865
+
866
+ if self._storage:
867
+ # Local mode: get stats from local storage
868
+ result = self._storage.get_metric_stats(
869
+ project=self.project,
870
+ experiment=self.name,
871
+ metric_name=name
872
+ )
873
+
874
+ return result
875
+
876
+ def _list_metrics(self) -> List[Dict[str, Any]]:
877
+ """
878
+ Internal method to list all metrics in experiment.
879
+
880
+ Returns:
881
+ List of metric summaries
882
+ """
883
+ result = None
884
+
885
+ if self._client:
886
+ # Remote mode: list via API
887
+ result = self._client.list_metrics(experiment_id=self._experiment_id)
888
+
889
+ if self._storage:
890
+ # Local mode: list from local storage
891
+ result = self._storage.list_metrics(
892
+ project=self.project,
893
+ experiment=self.name
894
+ )
895
+
896
+ return result or []
897
+
898
+ @property
899
+ def id(self) -> Optional[str]:
900
+ """Get the experiment ID (only available after open in remote mode)."""
901
+ return self._experiment_id
902
+
903
+ @property
904
+ def data(self) -> Optional[Dict[str, Any]]:
905
+ """Get the full experiment data (only available after open in remote mode)."""
906
+ return self._experiment_data
907
+
908
+
909
+ def ml_dash_experiment(
910
+ name: str,
911
+ project: str,
912
+ **kwargs
913
+ ) -> Callable:
914
+ """
915
+ Decorator for wrapping functions with an ML-Dash experiment.
916
+
917
+ Usage:
918
+ @ml_dash_experiment(
919
+ name="my-experiment",
920
+ project="my-project",
921
+ remote="http://localhost:3000",
922
+ api_key="your-token"
923
+ )
924
+ def train_model():
925
+ # Function code here
926
+ pass
927
+
928
+ The decorated function will receive an 'experiment' keyword argument
929
+ with the active Experiment instance.
930
+ """
931
+ def decorator(func: Callable) -> Callable:
932
+ @functools.wraps(func)
933
+ def wrapper(*args, **func_kwargs):
934
+ with Experiment(name=name, project=project, **kwargs) as experiment:
935
+ # Inject experiment into function kwargs
936
+ func_kwargs['experiment'] = experiment
937
+ return func(*args, **func_kwargs)
938
+ return wrapper
939
+ return decorator