ialdev-core 0.1.0__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.
Files changed (48) hide show
  1. iad/core/__init__.py +9 -0
  2. iad/core/array.py +1961 -0
  3. iad/core/binary.py +377 -0
  4. iad/core/cache.py +903 -0
  5. iad/core/codetools.py +203 -0
  6. iad/core/datatools.py +671 -0
  7. iad/core/docs/locators.ipynb +754 -0
  8. iad/core/dotstyle.py +99 -0
  9. iad/core/env.py +271 -0
  10. iad/core/events.py +650 -0
  11. iad/core/filesproc.py +1046 -0
  12. iad/core/fnctools.py +390 -0
  13. iad/core/label.py +240 -0
  14. iad/core/logs.py +182 -0
  15. iad/core/nptools.py +449 -0
  16. iad/core/one_dark.puml +881 -0
  17. iad/core/param/__init__.py +17 -0
  18. iad/core/param/confargparse.py +55 -0
  19. iad/core/param/paramaze.py +339 -0
  20. iad/core/param/tbox.py +277 -0
  21. iad/core/paths.py +563 -0
  22. iad/core/pdtools.py +2570 -0
  23. iad/core/pydantools/__init__.py +5 -0
  24. iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
  25. iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
  26. iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
  27. iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
  28. iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
  29. iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
  30. iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
  31. iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
  32. iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
  33. iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
  34. iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
  35. iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
  36. iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
  37. iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
  38. iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
  39. iad/core/pydantools/models.py +560 -0
  40. iad/core/regexp.py +348 -0
  41. iad/core/short.py +308 -0
  42. iad/core/strings.py +635 -0
  43. iad/core/unc_panda.py +270 -0
  44. iad/core/units.py +58 -0
  45. iad/core/wrap.py +420 -0
  46. ialdev_core-0.1.0.dist-info/METADATA +73 -0
  47. ialdev_core-0.1.0.dist-info/RECORD +48 -0
  48. ialdev_core-0.1.0.dist-info/WHEEL +4 -0
iad/core/events.py ADDED
@@ -0,0 +1,650 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import time
6
+ from collections import namedtuple
7
+ from pathlib import Path
8
+ from typing import Callable, Union, Iterable, Literal, Sequence, Iterator
9
+
10
+
11
+ class Triggers:
12
+ """Repository of triggers for actions based on certain conditions"""
13
+
14
+ def __init__(self):
15
+ self.triggers = []
16
+
17
+ def add(self, action: Callable,
18
+ cond: Union[Iterable, Callable, slice, int],
19
+ *, use_context=False):
20
+ """
21
+ Register action to trigger and when the condition satisfied.
22
+ :param action: Callable
23
+ :param cond:
24
+ int - interval, triggers for >0 and never if interval is 0
25
+ slice - triggers when slice samples occur
26
+ Iterable - triggers if any value from the set defined by this iterable
27
+ Callable - any custom function: `f(i: int) -> bool`
28
+ :param use_context: if True - expect to pass also a context dictionary into the action call
29
+ """
30
+ if isinstance(cond, slice):
31
+ slc = cond
32
+ step = 1 if slc.step is None else slc.step
33
+ start = 0 if slc.start is None else slc.start
34
+ stop = 2 ** 63 if slc.stop is None else slc.stop
35
+ cond = lambda x: start <= x < stop and ((x - start) % step == 0)
36
+ elif isinstance(cond, int):
37
+ step = cond
38
+ cond = lambda x: x > 0 and (x % step) == 0 if step else False
39
+ elif hasattr(cond, '__iter__'):
40
+ s = set(cond)
41
+ cond = lambda x: x in s
42
+ if not hasattr(cond, '__call__'):
43
+ raise TypeError(f'Invalid condition type: {type(cond)}! use int, slice, Iterable ort Callable')
44
+
45
+ self.triggers.append((cond, action, use_context))
46
+
47
+ def invoke(self, status, context=None):
48
+ """
49
+ Invoke triggers conditioned by the given status
50
+ :param status: usually time or simulation step
51
+ :param context: any data from the caller context to be passed to signed actions, usually locals()
52
+ """
53
+ for condition, action, use_context in self.triggers:
54
+ if condition(status):
55
+ action(context) if use_context else action()
56
+
57
+
58
+ def progress(*args, **kwargs):
59
+ """
60
+ Progress bar iterator wrapper.
61
+ Wraps any iterator adding progress bar functionality automatically detecting
62
+ terminal and notebook environments.
63
+
64
+ Example:
65
+ for x in progress(range(10)):
66
+ process(x)
67
+
68
+ See tqdm.tqdm for all the supported arguments
69
+ (if tqdm is not installed - just returns the iterator)
70
+
71
+ """
72
+ try:
73
+ import tqdm
74
+ except:
75
+ return args[0]
76
+
77
+ kwargs.setdefault('leave', False)
78
+ run_env = detect_frontend()
79
+ if run_env in ('terminal', 'ipython'):
80
+ return tqdm.tqdm(*args, **kwargs)
81
+ elif run_env == 'jupyter':
82
+ return tqdm.tqdm_notebook(*args, **kwargs, )
83
+
84
+ kwargs.setdefault('disable', True)
85
+ return tqdm.tqdm(*args, **kwargs)
86
+
87
+
88
+ class Timer:
89
+ """
90
+ Measures and reports time passed in the context
91
+ """
92
+
93
+ # ToDo: @Ilya unite with `timed` as they share overlapping functionality!
94
+ def __init__(self, msg='Completed in', out_func=print, *,
95
+ pre=None, sep='... ', active=True, min=0.0, fmt=' {:.3f}sec'):
96
+ """
97
+ Create timer object and pass (``msg`` + ``fmt``).format(time) into ``out_func`` when finished.
98
+
99
+ - If ``msg`` contains own `{...}` formatting, ``fmt`` is not used.
100
+ - Optionally ``pre`` message may be output in the beginning.
101
+
102
+ :param msg: message to display when reporting with {} to format in time in seconds
103
+ :param pre: preambule outputed before entering the body
104
+ :param sep: separator between the preambule and the completing message
105
+ :param out_func: function to be called for reporting
106
+ default - print, but may also use logging.info, warning, etc...
107
+ :param min: minimal time to report
108
+ :param active: if False deactivates - for silent modes
109
+ """
110
+ if isinstance(out_func, str):
111
+ out_func, *log_level = out_func.rsplit('.', 1)
112
+ log_level = (log_level[0] if log_level else 'debug').upper()
113
+ log_level = logging._nameToLevel.copy()[log_level]
114
+ logger = logging.getLogger(out_func)
115
+
116
+ active = logger.isEnabledFor(log_level)
117
+ out_func = lambda _: logger.log(log_level, _)
118
+ elif active: # if out_func is logger function deactivate if not enabled for this level
119
+ out_obj = out_func.__self__ # object containing func (Logger for log.debug)
120
+ if is_enabled := getattr(out_obj, 'isEnabledFor', None):
121
+ active = is_enabled(out_obj.level)
122
+
123
+ self.msg = '⏰' + msg + ('' if '{:' in msg else fmt)
124
+ self.pre = pre
125
+ self.out = out_func
126
+ self.sep = sep
127
+ self.active = active
128
+ self.min = min
129
+
130
+ def __enter__(self):
131
+ if self.active:
132
+ self.t0 = time.time()
133
+ if self.pre:
134
+ if self.out is print:
135
+ print(self.pre, end=self.sep)
136
+ else:
137
+ self.out(self.pre + self.sep)
138
+ return self
139
+
140
+ def __exit__(self, exc_type, exc_val, exc_tb):
141
+ if self.active:
142
+ dt = time.time() - self.t0
143
+ if self.pre or dt >= self.min:
144
+ self.out(self.msg.format(dt))
145
+
146
+
147
+ class TimePoints:
148
+ """
149
+ Time Points to place in the code and measure elapsed time between any two of them.
150
+ Points can be named to be *reference* later to measure time elapsed from then.
151
+ Points can be used for *measurements* by providing argument measure_from=<point_name>
152
+ Timing can be enabled / disabled by setting the corresponding attribute
153
+ """
154
+ MT = namedtuple('MT', ['cpu', 'wall'])
155
+
156
+ def __init__(self, enable=False, start=True, verb=True, progress=False,
157
+ clock: Literal['cpu', 'wall'] = 'wall'):
158
+ """
159
+ Initialize the time intervals measurements device.
160
+
161
+ :param enable: initial state
162
+ :param start: use this call as a 'start' point
163
+ :param verb: verbosity
164
+ :param progress: show the measurements along the progress
165
+ :param clock: use 'wall' or 'cpu' clock when measuring `longest` and `total`
166
+ """
167
+ self.moments: dict[str, TimePoints.MT] = dict()
168
+ self.enable = enable
169
+ self.progress = progress
170
+ self._verb = verb
171
+ self._last = None
172
+ self._clock = clock
173
+ self._record = {} # record of reports
174
+
175
+ self._wt0 = time.time()
176
+ if start:
177
+ self.point('start')
178
+
179
+ def verbose(self, verb=True):
180
+ self._verb = verb
181
+
182
+ def point(self, moment=None, *, measure_from=None,
183
+ message='Elapsed {time: 7.3f}s from {from_moment} to {moment}'):
184
+ """
185
+ Add timing point into the code.
186
+
187
+ :param moment: name of this point - if omitted would be point_N (N-number of points)
188
+ :param measure_from: name of a point in the past to use. May be called 'last'
189
+ :param message: Measurement result formatted string
190
+ :return: None
191
+
192
+ :Example:
193
+ tm = TimePoints(True)
194
+ ...
195
+ tm.point() # 'point_0' point is created
196
+ # some calculations
197
+ ...
198
+ tm.point(measure_from='last') # no reference point is created here
199
+ # some more calculations
200
+ ...
201
+ tm.point('big one', measure_from='last') # 'big one' created, measured from 'point_0'
202
+ # even more calculations
203
+ ...
204
+ tm.point() # 'point_1' created
205
+ # insanely heavy calculation
206
+ ...
207
+ tm.point(measure_from='big one') # measuring time elapsed from 'big_one'
208
+ tm.point(measure_from='last') # measuring time elapsed from 'point_1'
209
+
210
+ """
211
+ if not self.enable:
212
+ return
213
+
214
+ measure_from = measure_from or (
215
+ 'last' if self.moments and self.progress else None)
216
+
217
+ if measure_from == 'last' and self.moments:
218
+ measure_from = self._last
219
+
220
+ if not moment and not measure_from:
221
+ moment = 'point_' + str(len(self.moments))
222
+
223
+ tm = self.MT(time.process_time(), time.time() - self._wt0)
224
+ if moment:
225
+ self.moments[moment] = tm
226
+ self._last = moment
227
+
228
+ if measure_from:
229
+ prev_time = self.moments.get(measure_from, None)
230
+ if prev_time:
231
+ print(message.format(time=(tm.wall - prev_time.wall),
232
+ moment=moment, from_moment=measure_from))
233
+
234
+ def __call__(self, moment=None, *, measure_from=None,
235
+ message='Elapsed {time: 7.3f}s from {from_moment} to {moment}'):
236
+ return self.enable and self.point(moment, measure_from=measure_from, message=message)
237
+
238
+ def _measure_iter(self):
239
+ return (getattr(x, self._clock) for x in self.moments.values())
240
+
241
+ @property
242
+ def longest(self):
243
+ return max(self._measure_iter())
244
+
245
+ @property
246
+ def total(self):
247
+ return sum(self._measure_iter())
248
+
249
+ def report(self, title=None, *, show=True, record=False, min_time=0, **labels):
250
+ """Create report on measured time points so far.
251
+
252
+ :param title: Optional Title when printing
253
+ :param show: if True - print it
254
+ (also may pass columns to print from ['cpu', 'wall', 'dif'])
255
+ :param record: if True add it to the record,
256
+ labels and current timestamp used as keys
257
+ :param min_time: report only if total time exceeds this (sec)
258
+ :param labels: additional labels associated with this report,
259
+ will be printed as header to the time stats,
260
+ and used as a key of the record
261
+ :return: DataFrame with report
262
+ """
263
+ if not self.enable or self.total < min_time:
264
+ return
265
+ from pandas import DataFrame, option_context
266
+ df = DataFrame({m: tm._asdict() for m, tm in self.moments.items()}).T
267
+ df.index.name = 'TimePoints'
268
+ df = df.diff()[1:]
269
+ df['dif'] = df.cpu - df.wall
270
+ df.loc['TOTAL'] = df.sum()
271
+
272
+ if record:
273
+ self._record = {{**labels, 'ts': time.time()}: df}
274
+
275
+ if show:
276
+ from .strings import dict_str
277
+ labels = labels or dict_str(labels, sep='|')
278
+ title = title or ''
279
+ (title or labels) and print(f"⏰ {title}: {labels}")
280
+ if isinstance(show, str):
281
+ show = [show]
282
+ if isinstance(show, (list, tuple)):
283
+ df = df[[*show]]
284
+ with option_context('display.float_format', '{:,.3f}'.format):
285
+ print(df)
286
+ return df
287
+
288
+ def summary(self, measure: Literal['cpu', 'wall'] = 'wall'):
289
+ if not measure: return ''
290
+ _msr = lambda _: getattr(_, measure)
291
+ total = sum(map(_msr, self.moments.values()))
292
+ longest = max(self.moments.items(), key=lambda _: _msr(_[1]))
293
+ return f"[{measure}] total {total:.3f}s, longest: {longest[0]} {_msr(longest[1]):.3f}s"
294
+
295
+ def records(self):
296
+ # TODO: combine into one DataFrame with MiltiIndex
297
+ raise NotImplementedError
298
+
299
+
300
+ @contextlib.contextmanager
301
+ def tqdm_joblib(**kwargs):
302
+ """Context manager to patch joblib to report into tqdm progress bar given as argument"""
303
+ import joblib
304
+ from tqdm.auto import tqdm
305
+ tqdm_object = tqdm(**kwargs)
306
+
307
+ class TqdmBatchCompletionCallback(joblib.parallel.BatchCompletionCallBack):
308
+ def __init__(self, *args, **kwargs):
309
+ super().__init__(*args, **kwargs)
310
+
311
+ def __call__(self, *args, **kwargs):
312
+ tqdm_object.update(n=self.batch_size)
313
+ return super().__call__(*args, **kwargs)
314
+
315
+ old_batch_callback = joblib.parallel.BatchCompletionCallBack
316
+ joblib.parallel.BatchCompletionCallBack = TqdmBatchCompletionCallback
317
+ try:
318
+ yield tqdm_object
319
+ finally:
320
+ joblib.parallel.BatchCompletionCallBack = old_batch_callback
321
+ tqdm_object.close()
322
+
323
+
324
+ def detect_frontend():
325
+ """
326
+ Return running environment:
327
+ :return: 'jupyter notebook' | 'ipython terminal' | 'console'
328
+ """
329
+
330
+ from IPython import get_ipython
331
+
332
+ ip = get_ipython()
333
+ if ip:
334
+ ip_type = str(type(ip))
335
+ if 'ZMQInteractiveShell' in ip_type:
336
+ return 'jupyter'
337
+ if 'TerminalInteractiveShell' in ip_type:
338
+ return 'ipython'
339
+ return 'terminal'
340
+
341
+
342
+ def timed(report: Callable[[str], None] | logging.Logger | None = print, *,
343
+ cond: Callable[[], bool] | str | int | bool = True, min=0,
344
+ pre: Callable = None,
345
+ form: Callable = '⏰{func_name}{args} call took {time:.3f} sec'.format
346
+ ) -> Callable:
347
+ """
348
+ Create decorator to measure and report execution time of the function or method.
349
+
350
+ If ``report`` is either a ``Logger`` instance or a Callable receiving a message report string.
351
+
352
+ If Logger based reporter is enabled at the moment of the call, that also enable the time measurement.
353
+
354
+ To have measurements logged AND a Callable condition, use specific logging function:
355
+ ::
356
+ @timed(report=logger.debug, cond=my_condition)
357
+ ::
358
+
359
+ Argument ``cond`` controls when the measuring is invoked:
360
+ - ``False`` - skip creating decorator
361
+ - ``True`` - always measure
362
+ - ``Callable -> bool`` - function to decide each time (forbidden for Logger!)
363
+ - a `str` - one of the logging levels names (only for Logger!)
364
+ - an `int` - a logging level (only for Logger!)
365
+
366
+ Argument ``fmt`` allows to control formatting of the timing reports
367
+
368
+ Examples:
369
+ ::
370
+ @timed() # measure and print ALL the calls
371
+ @timed(log, 'INFO') # measure and log.info only if INFO level is enabled
372
+ @timed(print, False) # never
373
+ @timed(log.debug, True) # measure always, but report will appear only if debug is enabled
374
+
375
+ Decorator returns wrapper function with attribute ``_original_func``.
376
+ ::
377
+ @timed()(func)._original_func is func
378
+
379
+ To communicate timing results two argument are supported: ``report``, ``form``.
380
+ They are separated is a matter of convenience to allow independent
381
+ definition of "formatting" and "communication" methods:
382
+
383
+ | formatted = form(func_name=func_name, time=time, args=args, kwargs=kwargs)
384
+ | report(formatted)
385
+
386
+ ``form`` function is supposed to pack reported fields the into form acceptable by ``report`` function.
387
+ For example, format a string for logging:
388
+ ::
389
+ @timed(
390
+ report=logging.getLogger('timings').debug,
391
+ form="Function {func_name} executed in {time}s".format
392
+ )
393
+
394
+ However, ``report`` is optional, and ``form`` may implement all the required logic:
395
+ ::
396
+ events = {}
397
+
398
+ def record_event(self, func_name, time, args, kwargs):
399
+ events[func_name] = events.get(func_name, []).append(time)
400
+
401
+ @timed(
402
+ report=None,
403
+ form=record_event, ...
404
+ )
405
+
406
+
407
+ :param report: Optional `Callable` with single argument produced by calling ``form``.
408
+ :param cond: `False` - bypass wrapper, `True` - don't,
409
+ `Callable` - call before the function execution to decide if timing is needed.
410
+ :param min: minimal time to report, in sec
411
+ :param pre: optional str or format callable for message before the call
412
+ :param form: a Callable receiving call description parameters for message formatting
413
+ :return: decorator wrapping function if `cond` is not ``False`` or returning the original function.
414
+ """
415
+ from functools import wraps
416
+
417
+ if isinstance(report, logging.Logger):
418
+ logger = report
419
+ # cond in this case is converted either to True or False
420
+ if isinstance(cond, str):
421
+ cond = logging.getLevelName(cond)
422
+ if not isinstance(cond, int):
423
+ raise ValueError(f'Unknown logging level name {cond}')
424
+
425
+ if isinstance(cond, int):
426
+ level, cond = cond, True
427
+ elif cond is True:
428
+ level = logging.CRITICAL
429
+ elif cond is not False:
430
+ raise ValueError(f"{cond=} for Logger report, expected <logging level>str|int|bool")
431
+
432
+ report = lambda msg: logger.log(level, msg)
433
+
434
+ if not isinstance(cond, (Callable, bool)):
435
+ raise TypeError(f"Invalid {type(cond)=} when used with {report=}")
436
+
437
+ def decorator(f):
438
+ if cond is False:
439
+ return f
440
+ func_name = f.__qualname__
441
+
442
+ @wraps(f)
443
+ def wrapped(*args, **kwargs):
444
+ if cond is True or cond():
445
+ pre and report(pre(func_name=func_name, args=args, kwargs=kwargs)
446
+ if not isinstance(pre, str) else pre)
447
+ t0 = time.time()
448
+ res = f(*args, **kwargs)
449
+ t = time.time() - t0
450
+ if t > min:
451
+ formed = form(func_name=func_name, time=t, args=args, kwargs=kwargs)
452
+ report and report(formed) # when report=None form() could be processor
453
+ return res
454
+ else:
455
+ return f(*args, **kwargs)
456
+
457
+ wrapped._original_func = f
458
+ return wrapped
459
+
460
+ return decorator
461
+
462
+
463
+ EXEC_OUT = Literal['list', 'generator', 'generator_unordered']
464
+
465
+
466
+ @contextlib.contextmanager
467
+ def exec_SPMD(func: Callable, total: int, *,
468
+ jobs: dict | int | Literal[True, False, None],
469
+ split_from=8, out: EXEC_OUT = 'list', show: str | dict = ''):
470
+ """
471
+ Single Program Multiple Data execution context.
472
+
473
+ Given function and data size creates context to optionally apply it in parallel
474
+ using ``joblib`` package, if size and jobs parameters meet certain criteria.
475
+
476
+ Optionally shows progress bar, if desc or tqdm_par are provided
477
+ Example:
478
+
479
+ >>> with exec_SPMD(my_func, len(data), jobs=4) as (func, collect):
480
+ ... results = collect(func(item, **par) for item in data)
481
+
482
+ Argument ``jobs`` recieves parallelization settings in different forms:
483
+ - int > 1: number of jops to run
484
+ - True | None | -1: joblib automatically selects number of jobs
485
+ - False | 0 | 1 - don't activate `joblib` machinery at all
486
+
487
+ Argument ``data`` may be a size of data, or dataitem
488
+
489
+ Argument ``out`` controls how the output is organized:
490
+ "list" - collected into list (default)
491
+ "generator" - generator yielding results as soon as they ready in the input order
492
+ "generator_unordered" - same in arbitrary order (only for Parallel!)
493
+
494
+ :param func: function to apply - will be returned as is, or as ``joblib.delayed(func)``
495
+ :param jobs: parameters of ``joblib.Parallel`` | num of jobs | True|None|False
496
+ :param total: number of data items to be processed
497
+ :param split_from:
498
+ :param out: produce the outputs as a list or generator
499
+ :param show: Message to show with progress bar or dict with ``tqdm`` parameters
500
+ :return: collecting object, wrapped func
501
+ """
502
+ if jobs is True or jobs is None:
503
+ jobs = -1
504
+ if not isinstance(jobs, dict):
505
+ jobs_par = dict(n_jobs=jobs, return_as=out)
506
+ else:
507
+ jobs_par = jobs
508
+ jobs = jobs_par.setdefault('n_jobs', -1)
509
+
510
+ if not show:
511
+ tqdm_par = dict(disable=True)
512
+ elif isinstance(show, str):
513
+ tqdm_par = dict(desc=show, total=total)
514
+ else:
515
+ tqdm_par = dict(total=total) | show
516
+
517
+ if total >= split_from and (jobs < 0 or jobs > 1):
518
+ from joblib import delayed, Parallel, cpu_count
519
+
520
+ if jobs == -1:
521
+ jobs = jobs_par['n_jobs'] = cpu_count(only_physical_cores=True)
522
+ agg = Parallel(**jobs_par)
523
+ func = delayed(func)
524
+
525
+ tqdm = tqdm_joblib
526
+ if desc := tqdm_par.get('desc', None):
527
+ tqdm_par['desc'] = f"{desc} ({jobs=})"
528
+
529
+ else: # without joblib
530
+ from tqdm import tqdm as tqdm
531
+
532
+ def agg(results: Iterator):
533
+ def updating_iter(itr):
534
+ for x in itr:
535
+ pb.update(1)
536
+ yield x
537
+
538
+ if show:
539
+ results = updating_iter(results)
540
+ if out == 'list':
541
+ results = list(results)
542
+ return results
543
+
544
+ with tqdm(**tqdm_par) as pb: # pb used in agg.updating_iter
545
+ try:
546
+ yield agg, func
547
+ finally:
548
+ pass
549
+
550
+
551
+ def scheme_from_labels(labels: dict):
552
+ scheme = ""
553
+ for key in labels.keys():
554
+ scheme += f"{{{key}}}_"
555
+ scheme += "{*}.npy"
556
+ return scheme
557
+
558
+
559
+ class Dump(dict):
560
+ """
561
+ Class to dump data to a file, enables to trace location of the data along the flow of the algorithm.
562
+
563
+ For example, if running an ANN model, the dump class can be used in different parts of the flow
564
+ (encoder, decoder, etc.) to dump the data at different stages of the flow.
565
+ The location of along the tree is passed during the initial call of the class in the module.
566
+
567
+ The dump class can be used as a manager of dumping additional data from the algorithm,
568
+ initialized with a config file that controls weather a certain dump call should be implemented or not.
569
+ This way we can put the dump call in the code in various locations, and choose N
570
+ out of the allocated calls from the config file.
571
+ NOT IMPLEMENTED YET
572
+
573
+ for example:
574
+ >>> dump = Dump(config, root="path/to/root", labels={"label1": "value1", "label2": "value2"})
575
+ >>> dump(data, labels={"label3": "value3"}) # data is dumped .../value1_value2_[label3=value3].npy
576
+ >>> # or alternatively enables to update the labels
577
+ >>> dump(data, labels={"label1":"value12", "label3":"value3"}) # .../value12_value2_[label3=value3].npy
578
+
579
+ :param config: config file that controls the dump call, should have the following keys:
580
+ active: bool, weather to activate the dump call or not,
581
+ if False, the call will be ignored (default: False)
582
+ :param root: root path to dump the data to,
583
+ if None, the root path will be taken from the config file,
584
+ if active is False, root is ignored (default: None),
585
+ if active is True and root is None, an error is raised.
586
+ :param labels: dict, labels to add to the path, not mendatory,
587
+ but if None, labels should be passed in the call.
588
+ labels can also be passed in the config file,
589
+ and will be added (and overide) to the labels in the init.
590
+ Labels in the call will overide default labels.
591
+ scheme is build from the labels keys, the labels given in the init are the default labels.
592
+ :param scheme: str, scheme to build the path from, if None, the scheme will be built from the labels keys.
593
+ """
594
+
595
+ def __init__(self, config, root=None, labels: dict = None, scheme=None):
596
+ self.configured = None
597
+ self.name_builder = None
598
+ self.scheme = None
599
+ self.config = config
600
+ self.active = config.active if hasattr(config, 'active') else False
601
+ self.labels = labels
602
+ self.root = root
603
+ self.scheme = scheme
604
+ if self.active:
605
+ self.configure()
606
+
607
+ def configure(self, new_config=None):
608
+ from .paths import TransPath
609
+ # TODO add
610
+ # 1) support to selective config along the tree
611
+ # 2) clearer way to configure
612
+ if new_config is not None:
613
+ self.config.update(new_config)
614
+ self.root = Path(self.root) if self.root is not None else Path(self.config.root)
615
+ self.labels = self.labels or {} | self.config.labels
616
+ self.config.exist_ok = True if 'exist_ok' not in self.config.keys() else self.config.exist_ok
617
+ self.scheme = self.scheme or scheme_from_labels(self.labels)
618
+ self.name_builder = TransPath(self.scheme)
619
+ self.configured = True
620
+
621
+ def __call__(self, data=None, labels: dict = None, **kwargs):
622
+ import os
623
+ from iad.io import imsave
624
+
625
+ if not self.active or data is None:
626
+ return self.labels
627
+
628
+ self.configured or self.configure()
629
+ if labels is not None:
630
+ self.labels = self.labels | labels
631
+ else:
632
+ assert self.labels, "labels should be passed in the call or in the init"
633
+ # divide labels to default and anonymous
634
+ defaults = {key: value for key, value in self.labels.items() if
635
+ key in self.name_builder.regex.categories}
636
+ anonymous = {key: value for key, value in self.labels.items() if
637
+ key not in self.name_builder.regex.categories}
638
+ name = self.name_builder(anonymous, **defaults)
639
+ path = self.root / name
640
+ if os.path.isfile(path) and not self.config.exist_ok:
641
+ raise FileExistsError(f"File {path} already exists")
642
+ path.parent.mkdir(mode=0o777, parents=True, exist_ok=True)
643
+ # TODO add support for other file types
644
+ imsave(path, data)
645
+
646
+ def fork(self, other: dict):
647
+ return Dump(config=self.config, root=self.root, labels=other | self.labels, scheme=self.scheme)
648
+
649
+ def __repr__(self):
650
+ return f"Dump(config={self.config}, root={self.root}, labels={self.labels}, scheme={self.scheme})"