ml-dash 0.6.5__py3-none-any.whl → 0.6.7__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/files.py CHANGED
@@ -56,6 +56,7 @@ class FileBuilder:
56
56
  experiment.files.save_blob(b"xxx", to="data.bin")
57
57
  experiment.files.save_torch(model, to="model.pt")
58
58
  experiment.files.save_pkl(data, to="data.pkl")
59
+ experiment.files.save_image(array, to="frame.png")
59
60
  experiment.files.save_fig(fig, to="plot.png")
60
61
  experiment.files.save_video(frames, to="video.mp4")
61
62
  """
@@ -184,8 +185,9 @@ class FileBuilder:
184
185
  - str (file path): Uploads existing file
185
186
  - bytes: Saves as binary blob (requires 'to' parameter)
186
187
  - dict/list: Saves as JSON (requires 'to' parameter)
188
+ - numpy.ndarray: Saves as image (requires 'to' parameter with image extension)
187
189
  - None: Uses file_path from constructor (backwards compatibility)
188
- to: Target filename (required for bytes/dict/list, optional for file paths)
190
+ to: Target filename (required for bytes/dict/list/arrays, optional for file paths)
189
191
  description: Optional description
190
192
  tags: Optional list of tags
191
193
  metadata: Optional metadata dict
@@ -224,7 +226,17 @@ class FileBuilder:
224
226
  raise ValueError("'to' parameter is required when saving dict/list")
225
227
  return self.save_json(content, to=to)
226
228
 
227
- raise ValueError(f"Unsupported content type: {type(content)}. Expected str (file path), bytes, dict, or list.")
229
+ # Check if content is a numpy array (save as image)
230
+ try:
231
+ import numpy as np
232
+ if isinstance(content, np.ndarray):
233
+ if not to:
234
+ raise ValueError("'to' parameter is required when saving numpy arrays")
235
+ return self.save_image(content, to=to)
236
+ except ImportError:
237
+ pass # numpy not available
238
+
239
+ raise ValueError(f"Unsupported content type: {type(content)}. Expected str (file path), bytes, dict, list, or numpy.ndarray.")
228
240
 
229
241
  def _save_file(
230
242
  self,
@@ -288,23 +300,27 @@ class FileBuilder:
288
300
  temp_path = os.path.join(temp_dir, filename)
289
301
  # Create parent directories if filename contains path
290
302
  os.makedirs(os.path.dirname(temp_path), exist_ok=True)
291
- try:
292
- with open(temp_path, 'wb') as f:
293
- f.write(data)
294
- return self._save_file(
295
- fpath=temp_path,
296
- prefix=prefix,
297
- description=description,
298
- tags=tags,
299
- metadata=metadata
300
- )
301
- finally:
303
+
304
+ with open(temp_path, 'wb') as f:
305
+ f.write(data)
306
+ result = self._save_file(
307
+ fpath=temp_path,
308
+ prefix=prefix,
309
+ description=description,
310
+ tags=tags,
311
+ metadata=metadata
312
+ )
313
+
314
+ # Only clean up if NOT queued for buffered upload
315
+ if result.get("status") != "queued":
302
316
  try:
303
317
  os.unlink(temp_path)
304
318
  os.rmdir(temp_dir)
305
319
  except Exception:
306
320
  pass
307
321
 
322
+ return result
323
+
308
324
  def _save_json(
309
325
  self,
310
326
  content: Any,
@@ -323,23 +339,27 @@ class FileBuilder:
323
339
  temp_path = os.path.join(temp_dir, filename)
324
340
  # Create parent directories if filename contains path
325
341
  os.makedirs(os.path.dirname(temp_path), exist_ok=True)
326
- try:
327
- with open(temp_path, 'w') as f:
328
- json.dump(content, f, indent=2)
329
- return self._save_file(
330
- fpath=temp_path,
331
- prefix=prefix,
332
- description=description,
333
- tags=tags,
334
- metadata=metadata
335
- )
336
- finally:
342
+
343
+ with open(temp_path, 'w') as f:
344
+ json.dump(content, f, indent=2)
345
+ result = self._save_file(
346
+ fpath=temp_path,
347
+ prefix=prefix,
348
+ description=description,
349
+ tags=tags,
350
+ metadata=metadata
351
+ )
352
+
353
+ # Only clean up if NOT queued for buffered upload
354
+ if result.get("status") != "queued":
337
355
  try:
338
356
  os.unlink(temp_path)
339
357
  os.rmdir(temp_dir)
340
358
  except Exception:
341
359
  pass
342
360
 
361
+ return result
362
+
343
363
  def _save_torch(
344
364
  self,
345
365
  model: Any,
@@ -358,22 +378,26 @@ class FileBuilder:
358
378
  temp_path = os.path.join(temp_dir, filename)
359
379
  # Create parent directories if filename contains path
360
380
  os.makedirs(os.path.dirname(temp_path), exist_ok=True)
361
- try:
362
- torch.save(model, temp_path)
363
- return self._save_file(
364
- fpath=temp_path,
365
- prefix=prefix,
366
- description=description,
367
- tags=tags,
368
- metadata=metadata
369
- )
370
- finally:
381
+
382
+ torch.save(model, temp_path)
383
+ result = self._save_file(
384
+ fpath=temp_path,
385
+ prefix=prefix,
386
+ description=description,
387
+ tags=tags,
388
+ metadata=metadata
389
+ )
390
+
391
+ # Only clean up if NOT queued for buffered upload
392
+ if result.get("status") != "queued":
371
393
  try:
372
394
  os.unlink(temp_path)
373
395
  os.rmdir(temp_dir)
374
396
  except Exception:
375
397
  pass
376
398
 
399
+ return result
400
+
377
401
  def _save_fig(
378
402
  self,
379
403
  fig: Any,
@@ -393,23 +417,27 @@ class FileBuilder:
393
417
  temp_path = os.path.join(temp_dir, filename)
394
418
  # Create parent directories if filename contains path
395
419
  os.makedirs(os.path.dirname(temp_path), exist_ok=True)
396
- try:
397
- fig.savefig(temp_path, **kwargs)
398
- plt.close(fig)
399
- return self._save_file(
400
- fpath=temp_path,
401
- prefix=prefix,
402
- description=description,
403
- tags=tags,
404
- metadata=metadata
405
- )
406
- finally:
420
+
421
+ fig.savefig(temp_path, **kwargs)
422
+ plt.close(fig)
423
+ result = self._save_file(
424
+ fpath=temp_path,
425
+ prefix=prefix,
426
+ description=description,
427
+ tags=tags,
428
+ metadata=metadata
429
+ )
430
+
431
+ # Only clean up if NOT queued for buffered upload
432
+ if result.get("status") != "queued":
407
433
  try:
408
434
  os.unlink(temp_path)
409
435
  os.rmdir(temp_dir)
410
436
  except Exception:
411
437
  pass
412
438
 
439
+ return result
440
+
413
441
  def _save_pickle(
414
442
  self,
415
443
  content: Any,
@@ -428,23 +456,107 @@ class FileBuilder:
428
456
  temp_path = os.path.join(temp_dir, filename)
429
457
  # Create parent directories if filename contains path
430
458
  os.makedirs(os.path.dirname(temp_path), exist_ok=True)
459
+
460
+ with open(temp_path, 'wb') as f:
461
+ pickle.dump(content, f)
462
+ result = self._save_file(
463
+ fpath=temp_path,
464
+ prefix=prefix,
465
+ description=description,
466
+ tags=tags,
467
+ metadata=metadata
468
+ )
469
+
470
+ # Only clean up if NOT queued for buffered upload
471
+ if result.get("status") != "queued":
472
+ try:
473
+ os.unlink(temp_path)
474
+ os.rmdir(temp_dir)
475
+ except Exception:
476
+ pass
477
+
478
+ return result
479
+
480
+ def _save_image(
481
+ self,
482
+ array: Any,
483
+ filename: str,
484
+ prefix: str,
485
+ description: Optional[str],
486
+ tags: Optional[List[str]],
487
+ metadata: Optional[Dict[str, Any]],
488
+ quality: int = 95
489
+ ) -> Dict[str, Any]:
490
+ """Save numpy array as image file."""
491
+ import tempfile
492
+ import os
493
+
431
494
  try:
432
- with open(temp_path, 'wb') as f:
433
- pickle.dump(content, f)
434
- return self._save_file(
435
- fpath=temp_path,
436
- prefix=prefix,
437
- description=description,
438
- tags=tags,
439
- metadata=metadata
440
- )
441
- finally:
495
+ from PIL import Image
496
+ import numpy as np
497
+ except ImportError:
498
+ raise ImportError("PIL/Pillow is required for saving images. Install it with: pip install Pillow")
499
+
500
+ temp_dir = tempfile.mkdtemp()
501
+ temp_path = os.path.join(temp_dir, filename)
502
+ # Create parent directories if filename contains path
503
+ os.makedirs(os.path.dirname(temp_path), exist_ok=True)
504
+
505
+ # Convert numpy array to PIL Image
506
+ # Handle different array shapes and dtypes
507
+ if array.dtype == np.uint8:
508
+ img = Image.fromarray(array)
509
+ else:
510
+ # Normalize to 0-255 range for non-uint8 arrays
511
+ if array.max() <= 1.0:
512
+ # Assume normalized float in [0, 1]
513
+ array_uint8 = (array * 255).astype(np.uint8)
514
+ else:
515
+ # Scale to 0-255
516
+ array_uint8 = ((array - array.min()) / (array.max() - array.min()) * 255).astype(np.uint8)
517
+ img = Image.fromarray(array_uint8)
518
+
519
+ # Handle JPEG-specific requirements
520
+ file_ext = os.path.splitext(filename)[1].lower()
521
+ save_kwargs = {}
522
+ if file_ext in ['.jpg', '.jpeg']:
523
+ # JPEG doesn't support alpha channel, convert RGBA to RGB
524
+ if img.mode in ('RGBA', 'LA', 'P'):
525
+ # Create white background
526
+ background = Image.new('RGB', img.size, (255, 255, 255))
527
+ if img.mode == 'P':
528
+ img = img.convert('RGBA')
529
+ background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
530
+ img = background
531
+ elif img.mode not in ('RGB', 'L'):
532
+ # Convert other modes to RGB
533
+ img = img.convert('RGB')
534
+ # Set JPEG quality
535
+ save_kwargs['quality'] = quality
536
+ save_kwargs['optimize'] = True
537
+
538
+ # Save image to temp file
539
+ img.save(temp_path, **save_kwargs)
540
+
541
+ result = self._save_file(
542
+ fpath=temp_path,
543
+ prefix=prefix,
544
+ description=description,
545
+ tags=tags,
546
+ metadata=metadata
547
+ )
548
+
549
+ # Only clean up if NOT queued for buffered upload
550
+ # If queued, the buffer manager will clean up after upload
551
+ if result.get("status") != "queued":
442
552
  try:
443
553
  os.unlink(temp_path)
444
554
  os.rmdir(temp_dir)
445
555
  except Exception:
446
556
  pass
447
557
 
558
+ return result
559
+
448
560
  def list(self, pattern: Optional[str] = None) -> List[Dict[str, Any]]:
449
561
  """
450
562
  List files with optional glob pattern filtering.
@@ -881,6 +993,49 @@ class FileBuilder:
881
993
  metadata=self._metadata
882
994
  )
883
995
 
996
+ def save_image(
997
+ self,
998
+ array: Any,
999
+ *,
1000
+ to: str,
1001
+ quality: int = 95
1002
+ ) -> Dict[str, Any]:
1003
+ """
1004
+ Save numpy array as an image file.
1005
+
1006
+ Args:
1007
+ array: Numpy array representing the image (HxW or HxWxC)
1008
+ to: Target filename (must have image extension like .png, .jpg, .jpeg)
1009
+ quality: JPEG quality (1-100, default: 95). Only used for JPEG files.
1010
+
1011
+ Returns:
1012
+ File metadata dict with id, path, filename, checksum, etc.
1013
+
1014
+ Examples:
1015
+ # Save rendered frame from MuJoCo as PNG
1016
+ pixels = renderer.render()
1017
+ result = dxp.files("frames").save_image(pixels, to="frame_001.png")
1018
+
1019
+ # Save as JPEG with custom quality
1020
+ result = dxp.files("frames").save_image(pixels, to="frame_001.jpg", quality=85)
1021
+
1022
+ # Save numpy array as image
1023
+ import numpy as np
1024
+ img_array = np.random.rand(480, 640, 3) * 255
1025
+ result = dxp.files("images").save_image(img_array, to="random.png")
1026
+ """
1027
+ prefix = '/' + self._path.lstrip('/') if self._path else self._prefix
1028
+
1029
+ return self._save_image(
1030
+ array=array,
1031
+ filename=to,
1032
+ prefix=prefix,
1033
+ description=self._description,
1034
+ tags=self._tags,
1035
+ metadata=self._metadata,
1036
+ quality=quality
1037
+ )
1038
+
884
1039
  def save_fig(
885
1040
  self,
886
1041
  fig: Optional[Any] = None,
@@ -974,29 +1129,32 @@ class FileBuilder:
974
1129
  # Create parent directories if filename contains path
975
1130
  os.makedirs(os.path.dirname(temp_path), exist_ok=True)
976
1131
 
1132
+ frames_ubyte = img_as_ubyte(frame_stack)
977
1133
  try:
978
- frames_ubyte = img_as_ubyte(frame_stack)
979
- try:
980
- iio.imwrite(temp_path, frames_ubyte, fps=fps, **imageio_kwargs)
981
- except iio.core.NeedDownloadError:
982
- import imageio.plugins.ffmpeg
983
- imageio.plugins.ffmpeg.download()
984
- iio.imwrite(temp_path, frames_ubyte, fps=fps, **imageio_kwargs)
1134
+ iio.imwrite(temp_path, frames_ubyte, fps=fps, **imageio_kwargs)
1135
+ except iio.core.NeedDownloadError:
1136
+ import imageio.plugins.ffmpeg
1137
+ imageio.plugins.ffmpeg.download()
1138
+ iio.imwrite(temp_path, frames_ubyte, fps=fps, **imageio_kwargs)
1139
+
1140
+ result = self._save_file(
1141
+ fpath=temp_path,
1142
+ prefix=prefix,
1143
+ description=self._description,
1144
+ tags=self._tags,
1145
+ metadata=self._metadata
1146
+ )
985
1147
 
986
- return self._save_file(
987
- fpath=temp_path,
988
- prefix=prefix,
989
- description=self._description,
990
- tags=self._tags,
991
- metadata=self._metadata
992
- )
993
- finally:
1148
+ # Only clean up if NOT queued for buffered upload
1149
+ if result.get("status") != "queued":
994
1150
  try:
995
1151
  os.unlink(temp_path)
996
1152
  os.rmdir(temp_dir)
997
1153
  except Exception:
998
1154
  pass
999
1155
 
1156
+ return result
1157
+
1000
1158
  def duplicate(self, source: Union[str, Dict[str, Any]], to: str) -> Dict[str, Any]:
1001
1159
  """
1002
1160
  Duplicate an existing file to a new path within the same experiment.
ml_dash/run.py CHANGED
@@ -22,14 +22,43 @@ Usage:
22
22
  exp.logs.info(f"Running {RUN.name}")
23
23
  """
24
24
 
25
+ import functools
25
26
  import os
26
27
  import sys
28
+ import typing
27
29
  from datetime import datetime
28
30
  from pathlib import Path
29
- from typing import Union
31
+ from typing import Optional, Union
30
32
 
31
33
  from params_proto import EnvVar, proto
32
34
 
35
+
36
+ def requires_open(func):
37
+ """
38
+ Decorator that ensures the experiment is open before executing a method.
39
+
40
+ Raises:
41
+ RuntimeError: If experiment is not started
42
+ """
43
+
44
+ @functools.wraps(func)
45
+ def wrapper(self, *args, **kwargs):
46
+ if not self._is_open:
47
+ raise RuntimeError(
48
+ "Experiment not started. Use 'with experiment.run:' or call experiment.run.start() first.\n"
49
+ "Example:\n"
50
+ " with dxp.run:\n"
51
+ " dxp.params.set(lr=0.001)"
52
+ )
53
+ return func(self, *args, **kwargs)
54
+
55
+ return wrapper
56
+
57
+ if typing.TYPE_CHECKING:
58
+ from .client import RemoteClient
59
+ from .experiment import Experiment
60
+ from .storage import LocalStorage
61
+
33
62
  PROJECT_ROOT_FILES = ("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg")
34
63
 
35
64
 
@@ -77,12 +106,19 @@ def find_project_root(
77
106
  @proto.prefix
78
107
  class RUN:
79
108
  """
80
- Global Experiment Run Configuration.
109
+ Global Experiment Run Configuration and
110
+ Lifecycle manager for experiments
81
111
 
82
112
  This class is the single source of truth for experiment metadata.
83
113
  Configure it before starting an experiment, or through the Experiment
84
114
  constructor.
85
115
 
116
+ Supports three usage patterns:
117
+ 1. Method calls: experiment.run.start(), experiment.run.complete()
118
+ 2. Context manager: with Experiment(...).run as exp:
119
+ 3. Decorator: @exp.run or @Experiment(...).run
120
+
121
+
86
122
  Default prefix template:
87
123
  {project}/{now:%Y/%m-%d}/{path_stem}/{job_name}
88
124
 
@@ -156,10 +192,21 @@ class RUN:
156
192
  debug = "pydevd" in sys.modules
157
193
  "set to True automatically for pyCharm"
158
194
 
195
+ _experiment: "Experiment" = None
196
+ _client: Optional["RemoteClient"] = None
197
+ _storage: Optional["LocalStorage"] = None
198
+
199
+ # Prefix components (parsed from prefix)
200
+ owner: Optional[str] = None
201
+ name: Optional[str] = None
202
+ _folder_path: Optional[str] = None
203
+
159
204
  def __post_init__(self):
160
205
  """
206
+
161
207
  Initialize RUN with auto-detected prefix from entry path.
162
208
 
209
+
163
210
  Args:
164
211
  entry: Path to entry file/directory (e.g., __file__ or directory
165
212
  containing sweep.jsonl). If not provided, uses caller's
@@ -179,7 +226,6 @@ class RUN:
179
226
  RUN.__post_init__(entry=__file__)
180
227
  # Result: RUN.prefix = "vision/resnet", RUN.name = "resnet"
181
228
  """
182
-
183
229
  # Use provided entry or try to auto-detect from caller
184
230
  if self.entry is None:
185
231
  import inspect
@@ -221,6 +267,49 @@ class RUN:
221
267
  # for k, v in data.items():
222
268
  # print(f"> {k:>30}: {v}")
223
269
 
270
+ # Parse prefix into components: {owner}/{project}/path.../[name]
271
+ if self.prefix:
272
+ self._folder_path = self.prefix
273
+ parts = self.prefix.strip("/").split("/")
274
+ if len(parts) >= 2:
275
+ self.owner = parts[0]
276
+ self.project = parts[1]
277
+ # self.name is the last segment
278
+ self.name = parts[-1] if len(parts) > 2 else parts[1]
279
+
280
+ def start(self) -> "Experiment":
281
+ """
282
+ Start the experiment (sets status to RUNNING).
283
+
284
+ Returns:
285
+ The experiment instance for chaining
286
+ """
287
+ return self._experiment._open()
288
+
289
+ def complete(self) -> None:
290
+ """Mark experiment as completed (status: COMPLETED)."""
291
+ self._experiment._close(status="COMPLETED")
292
+
293
+ def fail(self) -> None:
294
+ """Mark experiment as failed (status: FAILED)."""
295
+ self._experiment._close(status="FAILED")
296
+
297
+ def cancel(self) -> None:
298
+ """Mark experiment as cancelled (status: CANCELLED)."""
299
+ self._experiment._close(status="CANCELLED")
300
+
301
+ def __enter__(self) -> "Experiment":
302
+ """Context manager entry - starts the experiment."""
303
+ return self.start()
304
+
305
+ def __exit__(self, exc_type, exc_val, exc_tb):
306
+ """Context manager exit - completes or fails the experiment."""
307
+ if exc_type is not None:
308
+ self.fail()
309
+ else:
310
+ self.complete()
311
+ return False
312
+
224
313
 
225
314
  if __name__ == "__main__":
226
315
  RUN.description = ""