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/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
|