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.
- iad/core/__init__.py +9 -0
- iad/core/array.py +1961 -0
- iad/core/binary.py +377 -0
- iad/core/cache.py +903 -0
- iad/core/codetools.py +203 -0
- iad/core/datatools.py +671 -0
- iad/core/docs/locators.ipynb +754 -0
- iad/core/dotstyle.py +99 -0
- iad/core/env.py +271 -0
- iad/core/events.py +650 -0
- iad/core/filesproc.py +1046 -0
- iad/core/fnctools.py +390 -0
- iad/core/label.py +240 -0
- iad/core/logs.py +182 -0
- iad/core/nptools.py +449 -0
- iad/core/one_dark.puml +881 -0
- iad/core/param/__init__.py +17 -0
- iad/core/param/confargparse.py +55 -0
- iad/core/param/paramaze.py +339 -0
- iad/core/param/tbox.py +277 -0
- iad/core/paths.py +563 -0
- iad/core/pdtools.py +2570 -0
- iad/core/pydantools/__init__.py +5 -0
- iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
- iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
- iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
- iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
- iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
- iad/core/pydantools/models.py +560 -0
- iad/core/regexp.py +348 -0
- iad/core/short.py +308 -0
- iad/core/strings.py +635 -0
- iad/core/unc_panda.py +270 -0
- iad/core/units.py +58 -0
- iad/core/wrap.py +420 -0
- ialdev_core-0.1.0.dist-info/METADATA +73 -0
- ialdev_core-0.1.0.dist-info/RECORD +48 -0
- 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})"
|