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/cache.py ADDED
@@ -0,0 +1,903 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from enum import IntEnum, auto
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from typing import List, Callable, Iterable, Union, get_type_hints, Iterator, Type
8
+
9
+ from joblib import load, dump
10
+ from tqdm import tqdm
11
+
12
+ from . import logs
13
+ from . import logger
14
+ from .events import Timer
15
+ from .filesproc import Locator
16
+ from .strings import hash_str
17
+
18
+ _log = logger('caching')
19
+ __all__ = ['CacheMode', 'CachedPipe', 'filecached', 'Cacher', 'Pickle', 'CacheInvalidError', 'AStage']
20
+
21
+
22
+ class CacheError(Exception):
23
+ pass
24
+
25
+
26
+ class CacheInvalidError(CacheError):
27
+ pass
28
+
29
+
30
+ # ------------ Define Serialization Protocols --------------------
31
+ class Serial(metaclass=abc.ABCMeta):
32
+ """Base Abstract class to define Serializers.
33
+
34
+ A serializer must define ``save`` and ``load`` static methods,
35
+ and ``ext`` attribute (extension string without dot).
36
+ """
37
+
38
+ def __repr__(self):
39
+ return f"{self.__class__.__name__}(.{self.ext})"
40
+
41
+ @classmethod
42
+ def io(cls, meth):
43
+ return cls.time_io(staticmethod(meth))
44
+
45
+ @classmethod
46
+ def time_io(cls, f):
47
+ @wraps(f)
48
+ def wrapper(file, *args):
49
+ if not _log.isEnabledFor(_log.DEBUG - 1):
50
+ return f(file, *args)
51
+
52
+ mode = '[v] save' if args else '[^] load'
53
+ with Timer(f'{mode} in {{:.3f}}sec {str(file)}', _log.debug):
54
+ return f(file, *args)
55
+
56
+ return wrapper
57
+
58
+ @property
59
+ @abc.abstractmethod
60
+ def ext(self): ...
61
+
62
+ @staticmethod
63
+ @abc.abstractmethod
64
+ def save(file: Path, records: List[dict]): ...
65
+
66
+ @staticmethod
67
+ @abc.abstractmethod
68
+ def load(file: Path): ...
69
+
70
+
71
+ class NoSerial(Serial):
72
+ """Dummy Serializer ensures it is never called"""
73
+ ext = 'xxx'
74
+
75
+ @staticmethod
76
+ def save(file: Path, records):
77
+ raise RuntimeError()
78
+
79
+ @staticmethod
80
+ def load(file: Path):
81
+ raise RuntimeError()
82
+
83
+
84
+ class Parquet(Serial):
85
+ _column_name = '___dumb_list_name__'
86
+ ext = 'pqt'
87
+
88
+ def __repr__(self):
89
+ return f"<{self.__class__.__name__}>"
90
+
91
+ @staticmethod
92
+ @Serial.io
93
+ def save(file: Path, records: List[dict]):
94
+ from pandas import DataFrame
95
+ if not isinstance(records[0], dict):
96
+ records = {Parquet._column_name: records}
97
+ file.parent.mkdir(exist_ok=True)
98
+ DataFrame(records).to_parquet(file)
99
+
100
+ @staticmethod
101
+ @Serial.io
102
+ def load(file: Path):
103
+ from pandas import read_parquet
104
+ df = read_parquet(file)
105
+ if Parquet._column_name in df:
106
+ return df[Parquet._column_name].values
107
+ return df.to_dict('records')
108
+
109
+
110
+ class Pickle(Serial):
111
+ ext = 'pkl'
112
+
113
+ @staticmethod
114
+ @Serial.time_io
115
+ def save(file: Path, records):
116
+ import pickle
117
+ file.parent.mkdir(exist_ok=True)
118
+ with open(file, 'wb') as fh:
119
+ pickle.dump(records, fh, pickle.HIGHEST_PROTOCOL)
120
+
121
+ @staticmethod
122
+ @Serial.time_io
123
+ def load(file: Path):
124
+ import pickle
125
+ with open(file, 'rb') as fh:
126
+ return pickle.load(fh)
127
+
128
+
129
+ # ----------------------------------------------------------
130
+ class CacheMode(IntEnum):
131
+ """Modes controlling caching behaviour:
132
+
133
+ - NONE: undefined mode, Typed equivalent of None
134
+ - PASS: bypass caching mechanism
135
+ - LOAD: load instead of processing
136
+ - SAVE: save processed
137
+ - KEEP: use available and create if missing
138
+ - CLEAR: clear caches and set to SAVE
139
+ """
140
+ NONE = 10000 # undefined mode, Typed equivalent of None, start far from small numbers
141
+ PASS = auto() # bypass
142
+ LOAD = auto() # load instead of processing
143
+ SAVE = auto() # save processed
144
+ KEEP = auto() # use available and create if missing
145
+ CLEAR = auto() # clear caches and set to SAVE
146
+
147
+ def __repr__(self):
148
+ return self.name
149
+
150
+ def __bool__(self):
151
+ return self.value is not CacheMode.NONE
152
+
153
+ @classmethod
154
+ def _missing_(cls, value):
155
+ if value is True: return cls.KEEP
156
+ if value is False: return cls.PASS
157
+ if isinstance(value, str):
158
+ return cls.__members__.get(value, None)
159
+
160
+ __str__ = __repr__
161
+
162
+
163
+ class AStage(metaclass=abc.ABCMeta):
164
+ """
165
+ Abstract cached pipeline stage definition.
166
+
167
+ Subclasses may extend stage definition parameters (`__init__`)
168
+ and *must* implement `next_func` factory method.
169
+
170
+ This method implements the essential logic of the particular subclass,
171
+ and must satisfy those requirements:
172
+ - receive previous cached stage object (`prev`) and
173
+ - returns a closure, which
174
+ - on every call pulls from the `prev` next data item
175
+ """
176
+
177
+ @abc.abstractmethod
178
+ def next_func(self, prev: CachedStage) -> Callable: ...
179
+
180
+ def __repr__(self):
181
+ return f"<{self.__class__.__name__}>[{self.name}]"
182
+
183
+ def __init__(self, name: str, *_, serial: Serial = Pickle(),
184
+ mode: CacheMode = None, copy: Callable = None, cfg: str = None):
185
+ """
186
+ Define pipeline stage
187
+ :param name: unique name of the stage (used in cache file)
188
+ :param serial: serialization class subclassing ``Serial``
189
+ :param mode: default cache mode or None to inherit from Pipe
190
+ :param copy: optional function to create copy of data items when placing them into
191
+ caching buffer - may be needed if stage yields unstable data items.
192
+ """
193
+ assert not _, "Only defines interface for subclasses"
194
+ self.name = name
195
+ self.serial = serial
196
+ self.mode = mode or CacheMode.NONE
197
+ self.copy = copy or (lambda _: _)
198
+ self.cfg = cfg
199
+
200
+
201
+ class CachedStage:
202
+
203
+ def __init__(self, st: AStage, prev: CachedStage,
204
+ folder: Path, mode=CacheMode.PASS, serial: Serial = None):
205
+ """
206
+ Initialize a processing stage layer positioned after given previous layer
207
+ serializing data in the given folder.
208
+
209
+ :param st: stage description in form of AStage
210
+ :param prev: previous cache level object in the pipeline
211
+ :param folder: location for cache files
212
+ :param mode: enumerated by CacheMode: [PASS] | LOAD | SAVE | KEEP
213
+ PASS = [default] to bypasses caching mechanism
214
+ LOAD - use available cache files, raise if not found!
215
+ SAVE - saves FULL iterations results into the file
216
+ KEEP - same as LOAD if file is found otherwise SAVE
217
+ """
218
+ if hasattr(st, '__length_hint__'):
219
+ self.__length_hint__ = st.__length_hint__
220
+
221
+ self.mode = CacheMode(mode if st.mode is CacheMode.NONE else st.mode)
222
+ use_cache = self.mode not in (CacheMode.NONE, CacheMode.PASS)
223
+ name = f"{st.name}_{hash_str(st.cfg, 3)}" if use_cache and st.cfg else st.name
224
+
225
+ self.type = type(st)
226
+ self.name = name
227
+ self.serial = serial or st.serial
228
+ self.next_func = st.next_func
229
+ self._copy = st.copy
230
+ self._loaded = None # content loaded from cache file
231
+ self.next = None
232
+ self.prev = prev
233
+
234
+ if prev:
235
+ self.prev.next = self
236
+ name = f"{prev.file.stem}_{name}" # file name to include all the previous stages
237
+ self.file = Path(folder, f"{name}.{self.serial.ext}")
238
+
239
+ if use_cache:
240
+ # If needed verify and preload cache content
241
+ if self.mode is CacheMode.KEEP: # if not possible switch into SAVE mode!
242
+ self._loaded = self._try_load_file(fail=False) # includes case of empty file
243
+ self.mode = CacheMode.LOAD if self._loaded else CacheMode.SAVE
244
+ elif self.mode is CacheMode.LOAD:
245
+ self._loaded = self._try_load_file(fail=True) # Could return [] if found file is empty
246
+
247
+ if self.mode is CacheMode.SAVE:
248
+ if not is_folder_writable(folder):
249
+ raise PermissionError(f"{folder} is not writable!")
250
+ if st.cfg:
251
+ Path(folder, f"{self.name}_cfg.yml").write_text(st.cfg)
252
+
253
+ _log.debug(f"+ Stage: {str(self):<30}")
254
+
255
+ def _try_load_file(self, *, fail=True) -> list | None:
256
+ """
257
+ Attempts to load the file and return buffer content as a list of items.
258
+
259
+ On error if `fail` is ``True`` raise ``FileNotFoundError`` otherwise return ``None``.
260
+ """
261
+ try:
262
+ if not self.file.exists():
263
+ raise FileNotFoundError(f"Cache file not found {self.file}")
264
+ if not self.file.stat().st_size:
265
+ raise FileNotFoundError(f"Cache file {self.file} is empty!")
266
+ buf = self.serial.load(self.file)
267
+ if len(buf) == 0:
268
+ _log.warning(f"Cache file {self.file} is empty!")
269
+ return buf
270
+ except Exception as ex:
271
+ if fail: raise ex
272
+ _log.info(f"{ex} in {self}")
273
+ return None
274
+
275
+ def __bool__(self):
276
+ return True
277
+
278
+ def __repr__(self):
279
+ return f"<{self.type.__name__}> '{self.name}' [{self.mode}]"
280
+
281
+ def __iter__(self):
282
+ if self.mode is CacheMode.LOAD: # ignore previous stages!
283
+ self._get_next = iter(self._loaded).__next__
284
+ else:
285
+ self._get_next = self.next_func(self.prev)
286
+ if self.mode is CacheMode.SAVE:
287
+ self._buf = []
288
+
289
+ return self
290
+
291
+ def __next__(self):
292
+ save_mode = self.mode == CacheMode.SAVE
293
+ try:
294
+ item = self._get_next()
295
+ save_mode and self._buf.append(self._copy(item))
296
+ return item
297
+ except StopIteration:
298
+ if save_mode:
299
+ self.serial.save(self.file, self._buf) # logged by save!
300
+ raise StopIteration
301
+
302
+ def __length_hint__(self):
303
+ if self.mode is CacheMode.LOAD:
304
+ return len(self._loaded)
305
+ if self.prev:
306
+ return self.prev.__length_hint__()
307
+ return _src_default_length
308
+
309
+
310
+ def pipestage(cls):
311
+ """Decorator to define Stage classes for CachedPipe.
312
+
313
+ Such class must:
314
+ - define its `essential`` attribute representing stages functionality
315
+ - implement ``next_func(self, prev: CachedStage)`` method
316
+
317
+ This method (given previous stage) must:
318
+ - initializes its iterator, and
319
+ - return a special function object (`nexter`)
320
+
321
+ Call to this object (`nexter`) must perform three actions:
322
+ 1. iterate over the created ``prev`` iterator (if prev exists)
323
+ 2. invoke ``essential`` functionality
324
+ 3. return result
325
+
326
+ Implementation of a typical processing stage:
327
+
328
+ >>> @pipestage
329
+ ... class ProcessingStage:
330
+ ... proc: Callable
331
+ ...
332
+ ... def next_func(self, prev):
333
+ ... it = iter(prev) # initialize iterator over prev
334
+ ... proc = self.proc # prepare processing
335
+ ... return lambda: proc(next(it)) # create function (1,2,3)
336
+ """
337
+ from dataclasses import dataclass, make_dataclass
338
+
339
+ @dataclass
340
+ class Stage:
341
+ name: str
342
+ serial: Serial = Pickle()
343
+ mode: CacheMode = CacheMode.NONE
344
+ copy: Callable = (lambda _: _) # function used to copy a data into buffer
345
+
346
+ def __repr__(self):
347
+ return f"<{self.__class__.__name__}>[{self.name}]"
348
+
349
+ def __post_init__(self):
350
+ for name, tp in get_type_hints(type(self)).items():
351
+ atr = getattr(self, name)
352
+ if not isinstance(atr, tp):
353
+ raise TypeError(f'Expected type of {name} is {tp}, not {type(atr)}!')
354
+
355
+ def wrap(cls):
356
+ cls = dataclass(cls)
357
+ assert isinstance(cls.next_func, Callable), "Stage must define `next_func(self, prev)` method!"
358
+ dc = make_dataclass(cls.__name__, [], bases=(Stage, cls))
359
+ return dc
360
+
361
+ return wrap(cls)
362
+
363
+
364
+ _src_default_length = 10
365
+
366
+
367
+ class CachedPipe:
368
+ """Pipeline envelops cached processing stages into Iterable.
369
+ ::
370
+ Pipeline: S1 -> S2 -> S3 ->...
371
+
372
+ On each iteration an item is pulled from the last stage of the pipeline,
373
+ causing it to pull next from the previous stage, and so on.
374
+
375
+ Caching is implemented for every stage separately to keep all the data items passed through the stage.
376
+
377
+ If valid cache is found for stage Si, data items are pulled from there,
378
+ skipping execution of this and all the earlier stages.
379
+
380
+ CachePipe provides three most common types of stages: ``Source``, ``Map``, and ``Filter``,
381
+
382
+ Additional may be created by sub-classing ``AStage`` class.
383
+ """
384
+
385
+ ProgressT = Union[Callable[[Iterable], Iterator], bool, dict]
386
+
387
+ class Source(AStage):
388
+ """
389
+ A simple ``Source`` stage built around an Iterable `src` object.
390
+
391
+ Its ``__next__`` just calls ``next(src)``.
392
+ """
393
+
394
+ def __init__(self, src: Iterable, name: str, *, serial: Serial = Pickle(),
395
+ mode: CacheMode = None, copy: Callable = None, cfg=None):
396
+ if copy is None:
397
+ copy = dict.copy
398
+ super().__init__(name=name, serial=serial, mode=mode, copy=copy, cfg=cfg)
399
+ self.src = src
400
+
401
+ def __iter__(self):
402
+ self.it = iter(self.prev)
403
+ return self
404
+
405
+ def __next__(self):
406
+ return next(self.it)
407
+
408
+ def next_func(self, _) -> Callable:
409
+ it = iter(self.src)
410
+ return it.__next__
411
+
412
+ def __length_hint__(self):
413
+ for len_attr in ['__len__', '__length_hint__']:
414
+ if len_fnc := getattr(self.src, len_attr, None):
415
+ return len_fnc()
416
+ return _src_default_length
417
+
418
+ class Filter(AStage):
419
+ """
420
+ A ``Filter`` stage is built around a boolean condition function.
421
+
422
+ Its ``__next__`` method pulls and discards items from the previous stage,
423
+ and yields an item only when ``condition(item)`` is satisfied.
424
+ """
425
+
426
+ def __init__(self, cond: Callable, name: str, *, serial: Serial = Pickle(),
427
+ mode: CacheMode = None, copy: Callable = None, cfg=None):
428
+ super().__init__(name=name, serial=serial, mode=mode, copy=copy, cfg=cfg)
429
+ self.cond = cond
430
+
431
+ def __iter__(self):
432
+ self.it = filter(self.cond, self.prev)
433
+ return self
434
+
435
+ def __next__(self):
436
+ return next(self.it)
437
+
438
+ def next_func(self, prev) -> Callable:
439
+ it = filter(self.cond, prev)
440
+ return it.__next__
441
+
442
+ class Map(AStage):
443
+ """
444
+ ``Map`` stage built around a ``proc`` function mapping an input item into any other item.
445
+ """
446
+
447
+ def __init__(self, proc: Callable, name: str, *, serial: Serial = Pickle(),
448
+ mode: CacheMode = None, copy: Callable = None, cfg=None):
449
+ super().__init__(name=name, serial=serial, mode=mode, copy=copy, cfg=cfg)
450
+ self.proc = proc
451
+
452
+ def next_func(self, prev) -> Callable:
453
+ it = iter(prev)
454
+ proc = self.proc
455
+ return lambda: proc(next(it))
456
+
457
+ StageType = Type[Union[Source, Filter, Map]]
458
+
459
+ def __init__(self, stages: List[AStage], *, folder, temp: Path | str | bool = False,
460
+ mode=CacheMode.PASS, serial: Serial = None, progress: ProgressT = None):
461
+ """Create cache manager for a scheme keeping files in given
462
+ folder if given or in scheme's root folder'
463
+
464
+ Initialize pipeline with give stages in required mode:
465
+ ::
466
+ PASS - bypass caching
467
+ SAVE - save stages results to cache files
468
+ LOAD - load from latest (by order) available stage cache
469
+ KEEP - LOAD in stages with cache SAVE otherwise
470
+ CLEAR - PASS but clears all the caches
471
+
472
+ Progress can be shown during the iterations if argument is:
473
+ - ``Callable(Iterable) -> Iterator`` like ``tqdm.tqdm``
474
+ - ``True`` - then ``tqdm`` is used
475
+ - ``dict`` with arguments for ``tqdm``
476
+
477
+ :param stages: list of processing stages as ``AStage`` objects.
478
+ :param folder: cache files location
479
+ :param temp: use of temporal folder as fallback
480
+ :param mode: one of CacheMode enumerated modes, could have different
481
+ effect depending on stage's cache file (as described above)
482
+ :param progress: show progress when iterating.
483
+ Disabled if ``None`` or ``False``
484
+ """
485
+ self.folder = Path(folder).expanduser().absolute()
486
+ _log.debug(f"<{type(self).__qualname__}> {len(stages)} stages {mode=} in {str(self.folder)}")
487
+ clear = mode is CacheMode.CLEAR and (mode := CacheMode.PASS) # CLEAR -> PASS
488
+
489
+ folders = [self.folder]
490
+ if temp:
491
+ if temp is True:
492
+ import tempfile
493
+ temp = Path(tempfile.gettempdir(), 'cache')
494
+ folders.append(Path(temp))
495
+
496
+ latest_load = None
497
+ for attempt, folder in enumerate(folders):
498
+ if attempt and latest_load: break # skip another attempt if already loaded
499
+ latest_load = self.first = self.last = None
500
+ try:
501
+ for i, stage in enumerate(stages):
502
+ self.last = CachedStage(stage, prev=self.last, folder=folder, mode=mode, serial=serial)
503
+ if self.last.mode is CacheMode.LOAD: # cached stage found and ready to load
504
+ latest_load = (i, self.last)
505
+ if not self.first:
506
+ self.first = self.last
507
+ except PermissionError as ex:
508
+ if attempt == len(folders) - 1: raise ex
509
+ logs.error(f"{ex} - Trying fallback {str(folders[-1])}")
510
+
511
+ if latest_load and _log.isEnabledFor(_log.DEBUG):
512
+ n, (i, stage) = len(stages), latest_load
513
+ _log.debug(f'Loading from cache [{i - n}|{n}]-th stage {stage} shadows preceding ({i})')
514
+
515
+ clear and self.clear()
516
+ self.progress = None
517
+ self.progress_bar(progress)
518
+
519
+ def progress_bar(self, progress: ProgressT):
520
+ """Set progress bar and return its previous state"""
521
+ prev = self.progress
522
+ if progress in (None, False) or isinstance(progress, Callable):
523
+ self.progress = progress
524
+ elif isinstance(progress, dict):
525
+ self.progress = lambda it: tqdm(it, **progress)
526
+ elif progress is True:
527
+ self.progress = tqdm
528
+ else:
529
+ raise TypeError(f"Invalid progress argument {progress}")
530
+ return prev
531
+
532
+ def stages(self, reverse=False):
533
+ """
534
+ Return tuple of all the stages in requested order
535
+ :param reverse: True to reverse from last to first
536
+ :return:
537
+ """
538
+ return tuple(self.stages_iter(reverse=reverse))
539
+
540
+ def stages_iter(self, reverse=False):
541
+ """Iterator over stages from first to last or reversed"""
542
+ direct, stage = ('prev', self.last) if reverse else ('next', self.first)
543
+ while stage:
544
+ yield stage
545
+ stage = getattr(stage, direct)
546
+
547
+ def clear(self):
548
+ _log.info(f"Clearing cache in {self.folder}")
549
+ stage = self.last
550
+ while stage:
551
+ (found := stage.file.exists()) and stage.file.unlink()
552
+ _log.debug(found and f"[x] delete {stage.file}..." or f"not found {stage.file}")
553
+ stage = stage.prev
554
+
555
+ def exists(self):
556
+ """If at least one stage cache is found"""
557
+ return self.last.file.exists()
558
+
559
+ def __iter__(self):
560
+ if self.progress:
561
+ return iter(self.progress(self.last))
562
+ return iter(self.last)
563
+
564
+ def __length_hint__(self):
565
+ return self.last.__length_hint__()
566
+
567
+ def __repr__(self):
568
+ lines = [f"<{self.__class__.__name__}> with stages:", *map(str, self.stages_iter())]
569
+ return '\n\t'.join(lines)
570
+
571
+ def cached_stage(self):
572
+ """Return latest stage with usable cache or None"""
573
+ for stage in self.stages_iter(reverse=True):
574
+ if stage.mode is CacheMode.LOAD:
575
+ return stage
576
+ return None
577
+
578
+
579
+ def file_namer(name: str | Callable):
580
+ if isinstance(name, (str, Path)):
581
+ def name_cache_file(*_, **__):
582
+ return name
583
+ elif isinstance(name, Callable):
584
+ name_cache_file = name
585
+ else:
586
+ raise TypeError(f'Unsupported {type(name)=} for cache file name')
587
+ return name_cache_file
588
+
589
+
590
+ def filecached(
591
+ func=None, *, file_name=None, mode: CacheMode = CacheMode.KEEP,
592
+ pack: Callable = None, unpack: Callable = None,
593
+ protocol='pickle', load=None, save=None):
594
+ """
595
+ Decorator to warp function calls into file-based caching.
596
+ :param func: function to cache its calls
597
+ :param file_name: either full path to the file or a function to create such
598
+ (it must accept all the arguments passed to `func`)
599
+ :param mode: CacheMode supported: KEEP, PASS, LOAD
600
+ :param pack: optional function to pack data before saving
601
+ :param unpack: optional function to unpack data after loading
602
+ :param protocol: 'pickle' | 'pandas' - defines load and save functions for the caching.
603
+ :param load: override cache loading function of the protocol
604
+ :param save: override cache saving function of the protocol
605
+ :return: cached function or maker of cache function initialized by given arguments
606
+ """
607
+ namer = file_namer(file_name)
608
+
609
+ if protocol == 'pickle':
610
+ import pickle
611
+ def _pickle_save(f, d):
612
+ with open(f, 'wb') as fp:
613
+ pickle.dump(d, fp)
614
+ def _pickle_load(f):
615
+ with open(f, 'rb') as fp:
616
+ return pickle.load(fp)
617
+ prot_save, prot_load = _pickle_save, _pickle_load
618
+ elif protocol == 'pandas':
619
+ from pandas import read_pickle as prot_load, to_pickle as prot_save
620
+ else:
621
+ raise NotImplementedError(f'Unknown protocol {protocol=}')
622
+
623
+ load = load or prot_load
624
+ save = save or prot_save
625
+ _load = unpack and (lambda f: unpack(load(f))) or load
626
+ _save = pack and (lambda f, d: save(f, pack(d))) or save
627
+
628
+ def cached_dec(_func):
629
+ if mode == CacheMode.PASS:
630
+ return _func
631
+
632
+ @wraps(_func)
633
+ def cached_func(*args, **kws):
634
+ cache_file = Path(namer(_func, *args, **kws))
635
+
636
+ if cache_file.is_file():
637
+ if mode in (CacheMode.LOAD, CacheMode.KEEP):
638
+ _log.debug(f'Loading {_func.__name__} results from {cache_file!s}')
639
+ return _load(cache_file)
640
+ elif mode is CacheMode.CLEAR:
641
+ cache_file.unlink()
642
+ elif mode is CacheMode.LOAD:
643
+ raise FileExistsError(f"LOAD cache required but not found: {cache_file!s}")
644
+
645
+ data = _func(*args, **kws)
646
+
647
+ if mode in (CacheMode.KEEP, CacheMode.SAVE):
648
+ _log.debug(f'Saving {_func.__name__} results to {cache_file!s}')
649
+ cache_file.parent.mkdir(exist_ok=True)
650
+ _save(cache_file, data)
651
+
652
+ return data
653
+
654
+ return cached_func
655
+
656
+ return cached_dec if func is None else cached_dec(func)
657
+
658
+
659
+ def name_by_call(func, *args, **kws):
660
+ from pickle import dumps
661
+ from hashlib import md5
662
+
663
+ def hashable_args(*all_args):
664
+ for arg in all_args:
665
+ try:
666
+ yield md5(dumps(arg)).hexdigest()
667
+ except:
668
+ pass
669
+
670
+ hashes = tuple(hashable_args(*args, *kws.items()))
671
+
672
+ args_hash = md5(dumps(hashes)).hexdigest()
673
+ name = f"{func.__qualname__}_{len(hashes)}_{args_hash}"
674
+ _log.debug(f'Generate {name=} using {len(hashes)}(of {len(args) + len(kws)}) hashable arguments')
675
+ return name
676
+
677
+
678
+ class Cacher:
679
+ """ ``Cacher`` class maintains context for otherwise stateless function ``filecache``.
680
+ It can be used by itself, or as a super-class, then providing support for caching of method calls.
681
+
682
+ Alternatively this class can be used to cache function calls while
683
+ separating chaing parameters initialization,
684
+ from wrapping a particular function into its own caching context.
685
+
686
+ >>> cacher = Cacher(folder='./tmp')
687
+ >>> cacher
688
+ <Cacher>[name=name_from_func, folder=./tmp] mode:KEEP, pack:None, unpack:None, protocol:pickle, load:None, save:None
689
+ >>> def func(x): return 10*x
690
+ >>> cacher(func)(2)
691
+ 20
692
+ """
693
+
694
+ # ----------------------------------------------------
695
+ def __init__(self, *, name=name_by_call, folder=None,
696
+ mode: CacheMode | bool = CacheMode.KEEP,
697
+ pack: Callable = None, unpack: Callable = None,
698
+ protocol='pickle', load=None, save=None):
699
+ self.name = name
700
+ self.folder = folder
701
+ self._cache_par = dict(mode=mode, pack=pack, unpack=unpack,
702
+ protocol=protocol, load=load, save=save)
703
+
704
+ def __copy__(self):
705
+ return self.context()
706
+
707
+ def __repr__(self):
708
+ form = lambda x: getattr(x, '__qualname__', str(x))
709
+ par = '\n\t'.join(f"{k}:{form(v)}" for k, v in self._cache_par.items())
710
+ return f"<Cacher>[name={form(self.name)}, folder={self.folder}]\n\t{par}"
711
+
712
+ def update(self, *, mode: CacheMode | bool = None, name=None, folder=None, **par):
713
+ """Update cacher parameters"""
714
+ if isinstance(mode, bool):
715
+ mode = mode and CacheMode.KEEP or CacheMode.PASS
716
+
717
+ mode is not None and par.update(mode=mode)
718
+ folder is not None and setattr(self, 'folder', folder)
719
+ name is not None and setattr(self, 'name', name)
720
+
721
+ if unknown := [*filter(lambda p: p not in self._cache_par, par)]:
722
+ raise NameError(f"Caching parameters {unknown} not among supported: {[*self._cache_par]}")
723
+ self._cache_par.update(**par)
724
+ return self
725
+
726
+ def context(self, *, name=None, folder=None, mode: CacheMode | bool = None, **par):
727
+ """Create a new caching context inheriting this cacher (self) parameters
728
+ and updating it with provided arguments.
729
+
730
+ The original (self) cacher remains intact.
731
+
732
+ >>> cacher = Cacher(folder='./tmp/.cache')
733
+ >>> special_cacher = cacher.context(folder = './tmp/.special_cache', name='special_func')
734
+ >>> special_func = special_cacher(lambda x: x*2)
735
+ >>> special_func(2)
736
+ 4
737
+ """
738
+ kws = vars(self).copy()
739
+ cache_par = kws.pop('_cache_par')
740
+ return self.__class__(**kws, **cache_par).update(name=name, folder=folder, mode=mode, **par)
741
+
742
+ def cached(self, func, *, name=None, folder=None, mode: CacheMode | bool = None, **par):
743
+ """Unless `mode` is ``False`` return cached version of the function
744
+ with optionally updated caching parameters, otherwise return the original function.
745
+
746
+ Arguments with ``None`` values are not updated!
747
+
748
+ >>> Cacher(folder='./tmp').cached(min, mode=CacheMode.PASS)(1, 2)
749
+ 1
750
+ """
751
+ if mode is False:
752
+ return func
753
+
754
+ if not par and all(map(lambda _: _ is None, [name, folder, mode])):
755
+ return filecached(func, file_name=self._file_name(), **self._cache_par)
756
+ return self.context(name=name, folder=folder, mode=mode, **par)(func)
757
+
758
+ def __call__(self, func=None, *,
759
+ name=None, folder=None, mode: CacheMode | bool = None, **par) -> Cacher | Callable:
760
+ """Syntactic sugar, calls ``context`` if func is None, ``cached`` otherwise."""
761
+ if func is None:
762
+ return self.context(name=name, folder=folder, mode=mode, **par)
763
+ return self.cached(func, name=name, folder=folder, mode=mode, **par)
764
+
765
+ def _file_name(self):
766
+ from pathlib import Path
767
+ name = self.name
768
+ folder = self.folder
769
+
770
+ try: # construct file name
771
+ if not name:
772
+ if folder or not (file_name := Path(self._cache_par['file_name'])):
773
+ raise RuntimeError()
774
+ else:
775
+ if folder := (folder or self.folder):
776
+ folder = Path(folder)
777
+
778
+ if isinstance(name, Callable): # if name is a function creating a name
779
+ file_name = name # the folder is optional and is used only
780
+ if folder: # if explicitly provided, then expecting
781
+ def file_name(_func, *args,
782
+ **kws): # name() to produce only name part
783
+ return folder / name(_func, *args, **kws)
784
+ else: # Otherwise, folder is mandatory!
785
+ file_name = folder / name
786
+ except:
787
+ raise RuntimeError(f'Cache path is not initialized: {folder=}, {name=}')
788
+ return file_name
789
+
790
+ def is_loading(self, fnc=None, *args, **kws):
791
+ """Simulates filecached logic to see if cache state will attempt to load"""
792
+ if not self._cache_par['mode'] in [CacheMode.KEEP, CacheMode.LOAD]:
793
+ return False
794
+ name = self._file_name()
795
+ if isinstance(name, str):
796
+ name = Path(name)
797
+ if isinstance(name, Path):
798
+ return name.exists()
799
+ return Path(file_namer(name)(fnc, *args, **kws)).exists()
800
+
801
+
802
+ def is_folder_writable(folder: Path):
803
+ """Check folder is writable by touching a file in it.
804
+ If folder is missing it is created.
805
+ """
806
+ folder = isinstance(folder, Path) and Path(folder) or folder
807
+
808
+ try:
809
+ folder.mkdir(parents=True, exist_ok=True)
810
+ try_file = folder / '_file_can_be_created_.check'
811
+ try_file.touch()
812
+ try_file.unlink()
813
+ except:
814
+ return False
815
+ return True
816
+
817
+
818
+ if __name__ == '__main__':
819
+ _log.setLevel(_log.DEBUG - 1)
820
+
821
+
822
+ def build(num=10, k=2, inc=1, flt=4):
823
+ return [
824
+ CachedPipe.Source(range(num), f'source_{num}'),
825
+ CachedPipe.Map(lambda x: x * k, f'mul_{k}'),
826
+ CachedPipe.Filter(lambda x: x % flt == 0, f'flt_{flt}'),
827
+ CachedPipe.Map(lambda x: x + inc, f'add_{inc}')
828
+ ]
829
+
830
+
831
+ fld = '/tmp/_cache'
832
+ res1 = [*CachedPipe(build(10, 2, 1), fld)]
833
+ print(res1)
834
+
835
+
836
+ class PersistCache:
837
+
838
+ @classmethod
839
+ def cachable(cls, *, namer=None, mode='rw', loc: Locator = None, enable=True):
840
+ """decorator around function to cache its calls"""
841
+ from functools import wraps
842
+ loc = loc or Locator(Path.cwd() / '.algutils_cache')
843
+
844
+ def decorator(func):
845
+ if enable is False:
846
+ return func
847
+ cacher = PersistCache(func, namer=namer, loc=loc, mode=mode)
848
+
849
+ @wraps(func)
850
+ def wrapper(**kwargs):
851
+ return cacher._cached_call(**kwargs)
852
+
853
+ return wrapper
854
+
855
+ return decorator
856
+
857
+ def __init__(self, proc, *, loc: Locator = None, mode: str = '',
858
+ namer: Callable = None, pre_save: Callable = None, post_load: Callable = None):
859
+ loc = loc or Locator(Path.cwd() / '.algutils_cache')
860
+ self._mode = mode or ''
861
+ self._proc = proc
862
+ self._pre = pre_save
863
+ self._post = post_load
864
+ self._loc = loc
865
+ self._namer = namer or self._default_namer
866
+
867
+ if self._mode:
868
+ if not set(self._mode).issubset('rw'):
869
+ raise ValueError(f"Invalid {mode = }, expecting a combination of 'rw'")
870
+
871
+ if not list(loc.defined()):
872
+ raise NotADirectoryError(f"No cache locations are defined by {loc}")
873
+
874
+ @staticmethod
875
+ def _default_namer(**kwargs):
876
+ return hex(hash(tuple(kwargs.items()))) + 'cached'
877
+
878
+ def _cached_call(self, **kwargs):
879
+ """
880
+ :param proc:
881
+ """
882
+ name = self._namer(**kwargs) if self._mode else None
883
+ if 'r' in self._mode:
884
+ if file := self._loc.first_file(name):
885
+ res = load(file)
886
+ _log.debug("Result of %s loaded from %s", self._proc, file)
887
+ return self._post(res) if self._post else res
888
+
889
+ res = self._proc(**kwargs)
890
+ if 'w' in self._mode:
891
+ for cache_folder in self._loc.defined():
892
+ try:
893
+ filename = cache_folder / name
894
+ filename.parent.mkdir(parents=True, exist_ok=True)
895
+ dump(self._pre(res) if self._pre else res, filename)
896
+ break
897
+ except Exception as ex:
898
+ if filename.exists(): filename.umlink()
899
+ _log.warning(f"Error dumping cache for {name}: {ex}")
900
+ else:
901
+ raise NotADirectoryError(f"Failed dumping algo cache for {name}")
902
+
903
+ return res