ml-dash 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ml_dash/experiment.py ADDED
@@ -0,0 +1,1116 @@
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 RunManager:
31
+ """
32
+ Lifecycle manager for experiments.
33
+
34
+ Supports three usage patterns:
35
+ 1. Method calls: experiment.run.start(), experiment.run.complete()
36
+ 2. Context manager: with Experiment(...).run as exp:
37
+ 3. Decorator: @exp.run or @Experiment(...).run
38
+ """
39
+
40
+ def __init__(self, experiment: "Experiment"):
41
+ """
42
+ Initialize RunManager.
43
+
44
+ Args:
45
+ experiment: Parent Experiment instance
46
+ """
47
+ self._experiment = experiment
48
+
49
+ def start(self) -> "Experiment":
50
+ """
51
+ Start the experiment (sets status to RUNNING).
52
+
53
+ Returns:
54
+ The experiment instance for chaining
55
+ """
56
+ return self._experiment._open()
57
+
58
+ def complete(self) -> None:
59
+ """Mark experiment as completed (status: COMPLETED)."""
60
+ self._experiment._close(status="COMPLETED")
61
+
62
+ def fail(self) -> None:
63
+ """Mark experiment as failed (status: FAILED)."""
64
+ self._experiment._close(status="FAILED")
65
+
66
+ def cancel(self) -> None:
67
+ """Mark experiment as cancelled (status: CANCELLED)."""
68
+ self._experiment._close(status="CANCELLED")
69
+
70
+ @property
71
+ def folder(self) -> Optional[str]:
72
+ """
73
+ Get the current folder for this experiment.
74
+
75
+ Returns:
76
+ Current folder path or None
77
+
78
+ Example:
79
+ current_folder = exp.run.folder
80
+ """
81
+ return self._experiment.folder
82
+
83
+ @folder.setter
84
+ def folder(self, value: Optional[str]) -> None:
85
+ """
86
+ Set the folder for this experiment before initialization.
87
+
88
+ This can ONLY be set before the experiment is started (initialized).
89
+ Once the experiment is opened, the folder cannot be changed.
90
+
91
+ Supports template variables:
92
+ - {RUN.name} - Experiment name
93
+ - {RUN.project} - Project name
94
+
95
+ Args:
96
+ value: Folder path with optional template variables
97
+ (e.g., "experiments/{RUN.name}" or None)
98
+
99
+ Raises:
100
+ RuntimeError: If experiment is already initialized/open
101
+
102
+ Examples:
103
+ from ml_dash import dxp
104
+
105
+ # Static folder
106
+ dxp.run.folder = "experiments/vision/resnet"
107
+
108
+ # Template with experiment name
109
+ dxp.run.folder = "/iclr_2024/{RUN.name}"
110
+
111
+ # Template with multiple variables
112
+ dxp.run.folder = "{RUN.project}/experiments/{RUN.name}"
113
+
114
+ # Now start the experiment
115
+ with dxp.run:
116
+ dxp.params.set(lr=0.001)
117
+ """
118
+ if self._experiment._is_open:
119
+ raise RuntimeError(
120
+ "Cannot change folder after experiment is initialized. "
121
+ "Set folder before calling start() or entering 'with' block."
122
+ )
123
+
124
+ # Process template variables if present
125
+ if value and '{RUN.' in value:
126
+ # Generate unique run ID (timestamp-based)
127
+ from datetime import datetime
128
+ run_timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
129
+
130
+ # Simple string replacement for template variables
131
+ # Supports: {RUN.name}, {RUN.project}, {RUN.id}, {RUN.timestamp}
132
+ replacements = {
133
+ '{RUN.name}': f"{self._experiment.name}_{run_timestamp}", # Unique name with timestamp
134
+ '{RUN.project}': self._experiment.project,
135
+ '{RUN.id}': run_timestamp, # Just the timestamp
136
+ '{RUN.timestamp}': run_timestamp, # Alias for id
137
+ }
138
+
139
+ # Replace all template variables
140
+ for template, replacement in replacements.items():
141
+ if template in value:
142
+ value = value.replace(template, replacement)
143
+
144
+ # Update the folder on the experiment
145
+ self._experiment.folder = value
146
+
147
+ def __enter__(self) -> "Experiment":
148
+ """Context manager entry - starts the experiment."""
149
+ return self.start()
150
+
151
+ def __exit__(self, exc_type, exc_val, exc_tb):
152
+ """Context manager exit - completes or fails the experiment."""
153
+ if exc_type is not None:
154
+ self.fail()
155
+ else:
156
+ self.complete()
157
+ return False
158
+
159
+ def __call__(self, func: Callable) -> Callable:
160
+ """
161
+ Decorator support for wrapping functions with experiment lifecycle.
162
+
163
+ Usage:
164
+ @exp.run
165
+ def train(exp):
166
+ exp.log("Training...")
167
+ """
168
+ @functools.wraps(func)
169
+ def wrapper(*args, **kwargs):
170
+ with self as exp:
171
+ return func(exp, *args, **kwargs)
172
+ return wrapper
173
+
174
+
175
+ class Experiment:
176
+ """
177
+ ML-Dash experiment for metricing experiments.
178
+
179
+ Usage examples:
180
+
181
+ # Remote mode
182
+ experiment = Experiment(
183
+ name="my-experiment",
184
+ project="my-project",
185
+ remote="https://api.dash.ml",
186
+ api_key="your-jwt-token"
187
+ )
188
+
189
+ # Local mode
190
+ experiment = Experiment(
191
+ name="my-experiment",
192
+ project="my-project",
193
+ local_path=".ml-dash"
194
+ )
195
+
196
+ # Context manager
197
+ with Experiment(...) as exp:
198
+ exp.log(...)
199
+
200
+ # Decorator
201
+ @ml_dash_experiment(name="exp", project="ws", remote="...")
202
+ def train():
203
+ ...
204
+ """
205
+
206
+ def __init__(
207
+ self,
208
+ name: str,
209
+ project: str,
210
+ *,
211
+ description: Optional[str] = None,
212
+ tags: Optional[List[str]] = None,
213
+ bindrs: Optional[List[str]] = None,
214
+ folder: Optional[str] = None,
215
+ metadata: Optional[Dict[str, Any]] = None,
216
+ # Mode configuration
217
+ remote: Optional[str] = None,
218
+ api_key: Optional[str] = None,
219
+ local_path: Optional[str] = None,
220
+ # Internal parameters
221
+ _write_protected: bool = False,
222
+ ):
223
+ """
224
+ Initialize an ML-Dash experiment.
225
+
226
+ Args:
227
+ name: Experiment name (unique within project)
228
+ project: Project name
229
+ description: Optional experiment description
230
+ tags: Optional list of tags
231
+ bindrs: Optional list of bindrs
232
+ folder: Optional folder path (e.g., "/experiments/baseline")
233
+ metadata: Optional metadata dict
234
+ remote: Remote API URL (e.g., "https://api.dash.ml")
235
+ api_key: JWT token for authentication (auto-loaded from storage if not provided)
236
+ local_path: Local storage root path (for local mode)
237
+ _write_protected: Internal parameter - if True, experiment becomes immutable after creation
238
+ """
239
+ self.name = name
240
+ self.project = project
241
+ self.description = description
242
+ self.tags = tags
243
+ self.bindrs = bindrs
244
+ self.folder = folder
245
+ self._write_protected = _write_protected
246
+ self.metadata = metadata
247
+
248
+ # Determine operation mode
249
+ if remote and local_path:
250
+ self.mode = OperationMode.HYBRID
251
+ elif remote:
252
+ self.mode = OperationMode.REMOTE
253
+ elif local_path:
254
+ self.mode = OperationMode.LOCAL
255
+ else:
256
+ raise ValueError(
257
+ "Must specify either 'remote' (with api_key) or 'local_path'"
258
+ )
259
+
260
+ # Initialize backend
261
+ self._client: Optional[RemoteClient] = None
262
+ self._storage: Optional[LocalStorage] = None
263
+ self._experiment_id: Optional[str] = None
264
+ self._experiment_data: Optional[Dict[str, Any]] = None
265
+ self._is_open = False
266
+ self._metrics_manager: Optional['MetricsManager'] = None # Cached metrics manager
267
+
268
+ if self.mode in (OperationMode.REMOTE, OperationMode.HYBRID):
269
+ # api_key can be None - RemoteClient will auto-load from storage
270
+ self._client = RemoteClient(base_url=remote, api_key=api_key)
271
+
272
+ if self.mode in (OperationMode.LOCAL, OperationMode.HYBRID):
273
+ if not local_path:
274
+ raise ValueError("local_path is required for local mode")
275
+ self._storage = LocalStorage(root_path=Path(local_path))
276
+
277
+ def _open(self) -> "Experiment":
278
+ """
279
+ Internal method to open the experiment (create or update on server/filesystem).
280
+
281
+ Returns:
282
+ self for chaining
283
+ """
284
+ if self._is_open:
285
+ return self
286
+
287
+ if self._client:
288
+ # Remote mode: create/update experiment via API
289
+ response = self._client.create_or_update_experiment(
290
+ project=self.project,
291
+ name=self.name,
292
+ description=self.description,
293
+ tags=self.tags,
294
+ bindrs=self.bindrs,
295
+ folder=self.folder,
296
+ write_protected=self._write_protected,
297
+ metadata=self.metadata,
298
+ )
299
+ self._experiment_data = response
300
+ self._experiment_id = response["experiment"]["id"]
301
+
302
+ if self._storage:
303
+ # Local mode: create experiment directory structure
304
+ self._storage.create_experiment(
305
+ project=self.project,
306
+ name=self.name,
307
+ description=self.description,
308
+ tags=self.tags,
309
+ bindrs=self.bindrs,
310
+ folder=self.folder,
311
+ metadata=self.metadata,
312
+ )
313
+
314
+ self._is_open = True
315
+ return self
316
+
317
+ def _close(self, status: str = "COMPLETED"):
318
+ """
319
+ Internal method to close the experiment and update status.
320
+
321
+ Args:
322
+ status: Status to set - "COMPLETED" (default), "FAILED", or "CANCELLED"
323
+ """
324
+ if not self._is_open:
325
+ return
326
+
327
+ # Flush any pending writes
328
+ if self._storage:
329
+ self._storage.flush()
330
+
331
+ # Update experiment status in remote mode
332
+ if self._client and self._experiment_id:
333
+ try:
334
+ self._client.update_experiment_status(
335
+ experiment_id=self._experiment_id,
336
+ status=status
337
+ )
338
+ except Exception as e:
339
+ # Log error but don't fail the close operation
340
+ print(f"Warning: Failed to update experiment status: {e}")
341
+
342
+ self._is_open = False
343
+
344
+ @property
345
+ def run(self) -> RunManager:
346
+ """
347
+ Get the RunManager for lifecycle operations.
348
+
349
+ Usage:
350
+ # Method calls
351
+ experiment.run.start()
352
+ experiment.run.complete()
353
+
354
+ # Context manager
355
+ with Experiment(...).run as exp:
356
+ exp.log("Training...")
357
+
358
+ # Decorator
359
+ @experiment.run
360
+ def train(exp):
361
+ exp.log("Training...")
362
+
363
+ Returns:
364
+ RunManager instance
365
+ """
366
+ return RunManager(self)
367
+
368
+ @property
369
+ def params(self) -> ParametersBuilder:
370
+ """
371
+ Get a ParametersBuilder for parameter operations.
372
+
373
+ Usage:
374
+ # Set parameters
375
+ experiment.params.set(lr=0.001, batch_size=32)
376
+
377
+ # Get parameters
378
+ params = experiment.params.get()
379
+
380
+ Returns:
381
+ ParametersBuilder instance
382
+
383
+ Raises:
384
+ RuntimeError: If experiment is not open
385
+ """
386
+ if not self._is_open:
387
+ raise RuntimeError(
388
+ "Experiment not started. Use 'with experiment.run:' or call experiment.run.start() first.\n"
389
+ "Example:\n"
390
+ " with dxp.run:\n"
391
+ " dxp.params.set(lr=0.001)"
392
+ )
393
+
394
+ return ParametersBuilder(self)
395
+
396
+ def log(
397
+ self,
398
+ message: Optional[str] = None,
399
+ level: Optional[str] = None,
400
+ metadata: Optional[Dict[str, Any]] = None,
401
+ **extra_metadata
402
+ ) -> Optional[LogBuilder]:
403
+ """
404
+ Create a log entry or return a LogBuilder for fluent API.
405
+
406
+ This method supports two styles:
407
+
408
+ 1. Fluent style (no message provided):
409
+ Returns a LogBuilder that allows chaining with level methods.
410
+
411
+ Examples:
412
+ experiment.log(metadata={"epoch": 1}).info("Training started")
413
+ experiment.log().error("Failed", error_code=500)
414
+
415
+ 2. Traditional style (message provided):
416
+ Writes the log immediately and returns None.
417
+
418
+ Examples:
419
+ experiment.log("Training started", level="info", epoch=1)
420
+ experiment.log("Training started") # Defaults to "info"
421
+
422
+ Args:
423
+ message: Optional log message (for traditional style)
424
+ level: Optional log level (for traditional style, defaults to "info")
425
+ metadata: Optional metadata dict
426
+ **extra_metadata: Additional metadata as keyword arguments
427
+
428
+ Returns:
429
+ LogBuilder if no message provided (fluent mode)
430
+ None if log was written directly (traditional mode)
431
+
432
+ Raises:
433
+ RuntimeError: If experiment is not open
434
+ ValueError: If log level is invalid
435
+ """
436
+ if not self._is_open:
437
+ raise RuntimeError(
438
+ "Experiment not started. Use 'with experiment.run:' or call experiment.run.start() first.\n"
439
+ "Example:\n"
440
+ " with dxp.run:\n"
441
+ " dxp.log().info('Training started')"
442
+ )
443
+
444
+ # Fluent mode: return LogBuilder
445
+ if message is None:
446
+ combined_metadata = {**(metadata or {}), **extra_metadata}
447
+ return LogBuilder(self, combined_metadata if combined_metadata else None)
448
+
449
+ # Traditional mode: write immediately
450
+ level = level or LogLevel.INFO.value # Default to "info"
451
+ level = LogLevel.validate(level) # Validate level
452
+
453
+ combined_metadata = {**(metadata or {}), **extra_metadata}
454
+ self._write_log(
455
+ message=message,
456
+ level=level,
457
+ metadata=combined_metadata if combined_metadata else None,
458
+ timestamp=None
459
+ )
460
+ return None
461
+
462
+ def _write_log(
463
+ self,
464
+ message: str,
465
+ level: str,
466
+ metadata: Optional[Dict[str, Any]],
467
+ timestamp: Optional[datetime]
468
+ ) -> None:
469
+ """
470
+ Internal method to write a log entry immediately.
471
+ No buffering - writes directly to storage/remote AND stdout/stderr.
472
+
473
+ Args:
474
+ message: Log message
475
+ level: Log level (already validated)
476
+ metadata: Optional metadata dict
477
+ timestamp: Optional custom timestamp (defaults to now)
478
+ """
479
+ log_entry = {
480
+ "timestamp": (timestamp or datetime.utcnow()).isoformat() + "Z",
481
+ "level": level,
482
+ "message": message,
483
+ }
484
+
485
+ if metadata:
486
+ log_entry["metadata"] = metadata
487
+
488
+ # Mirror to stdout/stderr before writing to storage
489
+ self._print_log(message, level, metadata)
490
+
491
+ # Write immediately (no buffering)
492
+ if self._client:
493
+ # Remote mode: send to API (wrapped in array for batch API)
494
+ self._client.create_log_entries(
495
+ experiment_id=self._experiment_id,
496
+ logs=[log_entry] # Single log in array
497
+ )
498
+
499
+ if self._storage:
500
+ # Local mode: write to file immediately
501
+ self._storage.write_log(
502
+ project=self.project,
503
+ experiment=self.name,
504
+ folder=self.folder,
505
+ message=log_entry["message"],
506
+ level=log_entry["level"],
507
+ metadata=log_entry.get("metadata"),
508
+ timestamp=log_entry["timestamp"]
509
+ )
510
+
511
+ def _print_log(
512
+ self,
513
+ message: str,
514
+ level: str,
515
+ metadata: Optional[Dict[str, Any]]
516
+ ) -> None:
517
+ """
518
+ Print log to stdout or stderr based on level.
519
+
520
+ ERROR and FATAL go to stderr, all others go to stdout.
521
+
522
+ Args:
523
+ message: Log message
524
+ level: Log level
525
+ metadata: Optional metadata dict
526
+ """
527
+ import sys
528
+
529
+ # Format the log message
530
+ level_upper = level.upper()
531
+
532
+ # Build metadata string if present
533
+ metadata_str = ""
534
+ if metadata:
535
+ # Format metadata as key=value pairs
536
+ pairs = [f"{k}={v}" for k, v in metadata.items()]
537
+ metadata_str = f" [{', '.join(pairs)}]"
538
+
539
+ # Format: [LEVEL] message [key=value, ...]
540
+ formatted_message = f"[{level_upper}] {message}{metadata_str}"
541
+
542
+ # Route to stdout or stderr based on level
543
+ if level in ("error", "fatal"):
544
+ print(formatted_message, file=sys.stderr)
545
+ else:
546
+ print(formatted_message, file=sys.stdout)
547
+
548
+ def files(self, **kwargs) -> FileBuilder:
549
+ """
550
+ Get a FileBuilder for fluent file operations.
551
+
552
+ Returns:
553
+ FileBuilder instance for chaining
554
+
555
+ Raises:
556
+ RuntimeError: If experiment is not open
557
+
558
+ Examples:
559
+ # Upload file
560
+ experiment.files(file_path="./model.pt", prefix="/models").save()
561
+
562
+ # List files
563
+ files = experiment.files().list()
564
+ files = experiment.files(prefix="/models").list()
565
+
566
+ # Download file
567
+ experiment.files(file_id="123").download()
568
+
569
+ # Delete file
570
+ experiment.files(file_id="123").delete()
571
+ """
572
+ if not self._is_open:
573
+ raise RuntimeError(
574
+ "Experiment not started. Use 'with experiment.run:' or call experiment.run.start() first.\n"
575
+ "Example:\n"
576
+ " with dxp.run:\n"
577
+ " dxp.files().save()"
578
+ )
579
+
580
+ return FileBuilder(self, **kwargs)
581
+
582
+ def _upload_file(
583
+ self,
584
+ file_path: str,
585
+ prefix: str,
586
+ filename: str,
587
+ description: Optional[str],
588
+ tags: Optional[List[str]],
589
+ metadata: Optional[Dict[str, Any]],
590
+ checksum: str,
591
+ content_type: str,
592
+ size_bytes: int
593
+ ) -> Dict[str, Any]:
594
+ """
595
+ Internal method to upload a file.
596
+
597
+ Args:
598
+ file_path: Local file path
599
+ prefix: Logical path prefix
600
+ filename: Original filename
601
+ description: Optional description
602
+ tags: Optional tags
603
+ metadata: Optional metadata
604
+ checksum: SHA256 checksum
605
+ content_type: MIME type
606
+ size_bytes: File size in bytes
607
+
608
+ Returns:
609
+ File metadata dict
610
+ """
611
+ result = None
612
+
613
+ if self._client:
614
+ # Remote mode: upload to API
615
+ result = self._client.upload_file(
616
+ experiment_id=self._experiment_id,
617
+ file_path=file_path,
618
+ prefix=prefix,
619
+ filename=filename,
620
+ description=description,
621
+ tags=tags,
622
+ metadata=metadata,
623
+ checksum=checksum,
624
+ content_type=content_type,
625
+ size_bytes=size_bytes
626
+ )
627
+
628
+ if self._storage:
629
+ # Local mode: copy to local storage
630
+ result = self._storage.write_file(
631
+ project=self.project,
632
+ experiment=self.name,
633
+ folder=self.folder,
634
+ file_path=file_path,
635
+ prefix=prefix,
636
+ filename=filename,
637
+ description=description,
638
+ tags=tags,
639
+ metadata=metadata,
640
+ checksum=checksum,
641
+ content_type=content_type,
642
+ size_bytes=size_bytes
643
+ )
644
+
645
+ return result
646
+
647
+ def _list_files(
648
+ self,
649
+ prefix: Optional[str] = None,
650
+ tags: Optional[List[str]] = None
651
+ ) -> List[Dict[str, Any]]:
652
+ """
653
+ Internal method to list files.
654
+
655
+ Args:
656
+ prefix: Optional prefix filter
657
+ tags: Optional tags filter
658
+
659
+ Returns:
660
+ List of file metadata dicts
661
+ """
662
+ files = []
663
+
664
+ if self._client:
665
+ # Remote mode: fetch from API
666
+ files = self._client.list_files(
667
+ experiment_id=self._experiment_id,
668
+ prefix=prefix,
669
+ tags=tags
670
+ )
671
+
672
+ if self._storage:
673
+ # Local mode: read from metadata file
674
+ files = self._storage.list_files(
675
+ project=self.project,
676
+ experiment=self.name,
677
+ prefix=prefix,
678
+ tags=tags
679
+ )
680
+
681
+ return files
682
+
683
+ def _download_file(
684
+ self,
685
+ file_id: str,
686
+ dest_path: Optional[str] = None
687
+ ) -> str:
688
+ """
689
+ Internal method to download a file.
690
+
691
+ Args:
692
+ file_id: File ID
693
+ dest_path: Optional destination path (defaults to original filename)
694
+
695
+ Returns:
696
+ Path to downloaded file
697
+ """
698
+ if self._client:
699
+ # Remote mode: download from API
700
+ return self._client.download_file(
701
+ experiment_id=self._experiment_id,
702
+ file_id=file_id,
703
+ dest_path=dest_path
704
+ )
705
+
706
+ if self._storage:
707
+ # Local mode: copy from local storage
708
+ return self._storage.read_file(
709
+ project=self.project,
710
+ experiment=self.name,
711
+ file_id=file_id,
712
+ dest_path=dest_path
713
+ )
714
+
715
+ raise RuntimeError("No client or storage configured")
716
+
717
+ def _delete_file(self, file_id: str) -> Dict[str, Any]:
718
+ """
719
+ Internal method to delete a file.
720
+
721
+ Args:
722
+ file_id: File ID
723
+
724
+ Returns:
725
+ Dict with id and deletedAt
726
+ """
727
+ result = None
728
+
729
+ if self._client:
730
+ # Remote mode: delete via API
731
+ result = self._client.delete_file(
732
+ experiment_id=self._experiment_id,
733
+ file_id=file_id
734
+ )
735
+
736
+ if self._storage:
737
+ # Local mode: soft delete in metadata
738
+ result = self._storage.delete_file(
739
+ project=self.project,
740
+ experiment=self.name,
741
+ file_id=file_id
742
+ )
743
+
744
+ return result
745
+
746
+ def _update_file(
747
+ self,
748
+ file_id: str,
749
+ description: Optional[str],
750
+ tags: Optional[List[str]],
751
+ metadata: Optional[Dict[str, Any]]
752
+ ) -> Dict[str, Any]:
753
+ """
754
+ Internal method to update file metadata.
755
+
756
+ Args:
757
+ file_id: File ID
758
+ description: Optional description
759
+ tags: Optional tags
760
+ metadata: Optional metadata
761
+
762
+ Returns:
763
+ Updated file metadata dict
764
+ """
765
+ result = None
766
+
767
+ if self._client:
768
+ # Remote mode: update via API
769
+ result = self._client.update_file(
770
+ experiment_id=self._experiment_id,
771
+ file_id=file_id,
772
+ description=description,
773
+ tags=tags,
774
+ metadata=metadata
775
+ )
776
+
777
+ if self._storage:
778
+ # Local mode: update in metadata file
779
+ result = self._storage.update_file_metadata(
780
+ project=self.project,
781
+ experiment=self.name,
782
+ file_id=file_id,
783
+ description=description,
784
+ tags=tags,
785
+ metadata=metadata
786
+ )
787
+
788
+ return result
789
+
790
+
791
+ def _write_params(self, flattened_params: Dict[str, Any]) -> None:
792
+ """
793
+ Internal method to write/merge parameters.
794
+
795
+ Args:
796
+ flattened_params: Already-flattened parameter dict with dot notation
797
+ """
798
+ if self._client:
799
+ # Remote mode: send to API
800
+ self._client.set_parameters(
801
+ experiment_id=self._experiment_id,
802
+ data=flattened_params
803
+ )
804
+
805
+ if self._storage:
806
+ # Local mode: write to file
807
+ self._storage.write_parameters(
808
+ project=self.project,
809
+ experiment=self.name,
810
+ folder=self.folder,
811
+ data=flattened_params
812
+ )
813
+
814
+ def _read_params(self) -> Optional[Dict[str, Any]]:
815
+ """
816
+ Internal method to read parameters.
817
+
818
+ Returns:
819
+ Flattened parameters dict, or None if no parameters exist
820
+ """
821
+ params = None
822
+
823
+ if self._client:
824
+ # Remote mode: fetch from API
825
+ try:
826
+ params = self._client.get_parameters(experiment_id=self._experiment_id)
827
+ except Exception:
828
+ # Parameters don't exist yet
829
+ params = None
830
+
831
+ if self._storage:
832
+ # Local mode: read from file
833
+ params = self._storage.read_parameters(
834
+ project=self.project,
835
+ experiment=self.name
836
+ )
837
+
838
+ return params
839
+
840
+ @property
841
+ def metrics(self) -> 'MetricsManager':
842
+ """
843
+ Get a MetricsManager for metric operations.
844
+
845
+ Supports two usage patterns:
846
+ 1. Named: experiment.metrics("loss").append(value=0.5, step=1)
847
+ 2. Unnamed: experiment.metrics.append(name="loss", value=0.5, step=1)
848
+
849
+ Returns:
850
+ MetricsManager instance
851
+
852
+ Raises:
853
+ RuntimeError: If experiment is not open
854
+
855
+ Examples:
856
+ # Named metric
857
+ experiment.metrics("train_loss").append(value=0.5, step=100)
858
+
859
+ # Unnamed (name in append call)
860
+ experiment.metrics.append(name="train_loss", value=0.5, step=100)
861
+
862
+ # Append batch
863
+ experiment.metrics("metrics").append_batch([
864
+ {"loss": 0.5, "acc": 0.8, "step": 1},
865
+ {"loss": 0.4, "acc": 0.85, "step": 2}
866
+ ])
867
+
868
+ # Read data
869
+ data = experiment.metrics("train_loss").read(start_index=0, limit=100)
870
+
871
+ # Get statistics
872
+ stats = experiment.metrics("train_loss").stats()
873
+ """
874
+ from .metric import MetricsManager
875
+
876
+ if not self._is_open:
877
+ raise RuntimeError(
878
+ "Cannot use metrics on closed experiment. "
879
+ "Use 'with Experiment(...).run as experiment:' or call experiment.run.start() first."
880
+ )
881
+
882
+ # Cache the MetricsManager instance to preserve MetricBuilder cache across calls
883
+ if self._metrics_manager is None:
884
+ self._metrics_manager = MetricsManager(self)
885
+ return self._metrics_manager
886
+
887
+ def _append_to_metric(
888
+ self,
889
+ name: Optional[str],
890
+ data: Dict[str, Any],
891
+ description: Optional[str],
892
+ tags: Optional[List[str]],
893
+ metadata: Optional[Dict[str, Any]]
894
+ ) -> Dict[str, Any]:
895
+ """
896
+ Internal method to append a single data point to a metric.
897
+
898
+ Args:
899
+ name: Metric name (can be None for unnamed metrics)
900
+ data: Data point (flexible schema)
901
+ description: Optional metric description
902
+ tags: Optional tags
903
+ metadata: Optional metadata
904
+
905
+ Returns:
906
+ Dict with metricId, index, bufferedDataPoints, chunkSize
907
+ """
908
+ result = None
909
+
910
+ if self._client:
911
+ # Remote mode: append via API
912
+ result = self._client.append_to_metric(
913
+ experiment_id=self._experiment_id,
914
+ metric_name=name,
915
+ data=data,
916
+ description=description,
917
+ tags=tags,
918
+ metadata=metadata
919
+ )
920
+
921
+ if self._storage:
922
+ # Local mode: append to local storage
923
+ result = self._storage.append_to_metric(
924
+ project=self.project,
925
+ experiment=self.name,
926
+ folder=self.folder,
927
+ metric_name=name,
928
+ data=data,
929
+ description=description,
930
+ tags=tags,
931
+ metadata=metadata
932
+ )
933
+
934
+ return result
935
+
936
+ def _append_batch_to_metric(
937
+ self,
938
+ name: Optional[str],
939
+ data_points: List[Dict[str, Any]],
940
+ description: Optional[str],
941
+ tags: Optional[List[str]],
942
+ metadata: Optional[Dict[str, Any]]
943
+ ) -> Dict[str, Any]:
944
+ """
945
+ Internal method to append multiple data points to a metric.
946
+
947
+ Args:
948
+ name: Metric name (can be None for unnamed metrics)
949
+ data_points: List of data points
950
+ description: Optional metric description
951
+ tags: Optional tags
952
+ metadata: Optional metadata
953
+
954
+ Returns:
955
+ Dict with metricId, startIndex, endIndex, count
956
+ """
957
+ result = None
958
+
959
+ if self._client:
960
+ # Remote mode: append batch via API
961
+ result = self._client.append_batch_to_metric(
962
+ experiment_id=self._experiment_id,
963
+ metric_name=name,
964
+ data_points=data_points,
965
+ description=description,
966
+ tags=tags,
967
+ metadata=metadata
968
+ )
969
+
970
+ if self._storage:
971
+ # Local mode: append batch to local storage
972
+ result = self._storage.append_batch_to_metric(
973
+ project=self.project,
974
+ experiment=self.name,
975
+ metric_name=name,
976
+ data_points=data_points,
977
+ description=description,
978
+ tags=tags,
979
+ metadata=metadata
980
+ )
981
+
982
+ return result
983
+
984
+ def _read_metric_data(
985
+ self,
986
+ name: str,
987
+ start_index: int,
988
+ limit: int
989
+ ) -> Dict[str, Any]:
990
+ """
991
+ Internal method to read data points from a metric.
992
+
993
+ Args:
994
+ name: Metric name
995
+ start_index: Starting index
996
+ limit: Max points to read
997
+
998
+ Returns:
999
+ Dict with data, startIndex, endIndex, total, hasMore
1000
+ """
1001
+ result = None
1002
+
1003
+ if self._client:
1004
+ # Remote mode: read via API
1005
+ result = self._client.read_metric_data(
1006
+ experiment_id=self._experiment_id,
1007
+ metric_name=name,
1008
+ start_index=start_index,
1009
+ limit=limit
1010
+ )
1011
+
1012
+ if self._storage:
1013
+ # Local mode: read from local storage
1014
+ result = self._storage.read_metric_data(
1015
+ project=self.project,
1016
+ experiment=self.name,
1017
+ metric_name=name,
1018
+ start_index=start_index,
1019
+ limit=limit
1020
+ )
1021
+
1022
+ return result
1023
+
1024
+ def _get_metric_stats(self, name: str) -> Dict[str, Any]:
1025
+ """
1026
+ Internal method to get metric statistics.
1027
+
1028
+ Args:
1029
+ name: Metric name
1030
+
1031
+ Returns:
1032
+ Dict with metric stats
1033
+ """
1034
+ result = None
1035
+
1036
+ if self._client:
1037
+ # Remote mode: get stats via API
1038
+ result = self._client.get_metric_stats(
1039
+ experiment_id=self._experiment_id,
1040
+ metric_name=name
1041
+ )
1042
+
1043
+ if self._storage:
1044
+ # Local mode: get stats from local storage
1045
+ result = self._storage.get_metric_stats(
1046
+ project=self.project,
1047
+ experiment=self.name,
1048
+ metric_name=name
1049
+ )
1050
+
1051
+ return result
1052
+
1053
+ def _list_metrics(self) -> List[Dict[str, Any]]:
1054
+ """
1055
+ Internal method to list all metrics in experiment.
1056
+
1057
+ Returns:
1058
+ List of metric summaries
1059
+ """
1060
+ result = None
1061
+
1062
+ if self._client:
1063
+ # Remote mode: list via API
1064
+ result = self._client.list_metrics(experiment_id=self._experiment_id)
1065
+
1066
+ if self._storage:
1067
+ # Local mode: list from local storage
1068
+ result = self._storage.list_metrics(
1069
+ project=self.project,
1070
+ experiment=self.name
1071
+ )
1072
+
1073
+ return result or []
1074
+
1075
+ @property
1076
+ def id(self) -> Optional[str]:
1077
+ """Get the experiment ID (only available after open in remote mode)."""
1078
+ return self._experiment_id
1079
+
1080
+ @property
1081
+ def data(self) -> Optional[Dict[str, Any]]:
1082
+ """Get the full experiment data (only available after open in remote mode)."""
1083
+ return self._experiment_data
1084
+
1085
+
1086
+ def ml_dash_experiment(
1087
+ name: str,
1088
+ project: str,
1089
+ **kwargs
1090
+ ) -> Callable:
1091
+ """
1092
+ Decorator for wrapping functions with an ML-Dash experiment.
1093
+
1094
+ Usage:
1095
+ @ml_dash_experiment(
1096
+ name="my-experiment",
1097
+ project="my-project",
1098
+ remote="https://api.dash.ml",
1099
+ api_key="your-token"
1100
+ )
1101
+ def train_model():
1102
+ # Function code here
1103
+ pass
1104
+
1105
+ The decorated function will receive an 'experiment' keyword argument
1106
+ with the active Experiment instance.
1107
+ """
1108
+ def decorator(func: Callable) -> Callable:
1109
+ @functools.wraps(func)
1110
+ def wrapper(*args, **func_kwargs):
1111
+ with Experiment(name=name, project=project, **kwargs).run as experiment:
1112
+ # Inject experiment into function kwargs
1113
+ func_kwargs['experiment'] = experiment
1114
+ return func(*args, **func_kwargs)
1115
+ return wrapper
1116
+ return decorator