ialdev-dataman 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/dataman/__init__.py +22 -0
- iad/dataman/datacast/__init__.py +8 -0
- iad/dataman/datacast/caster.py +523 -0
- iad/dataman/datacast/collect.py +1466 -0
- iad/dataman/datacast/labeled.py +80 -0
- iad/dataman/datacast/scan.py +178 -0
- iad/dataman/datacast/transforms.py +40 -0
- iad/dataman/datacast/transtools.py +608 -0
- iad/dataman/env.py +10 -0
- iad/dataman/factories.py +143 -0
- iad/dataman/models.py +425 -0
- iad/dataman/resman.py +1229 -0
- ialdev_dataman-0.1.0.dist-info/METADATA +33 -0
- ialdev_dataman-0.1.0.dist-info/RECORD +15 -0
- ialdev_dataman-0.1.0.dist-info/WHEEL +4 -0
iad/dataman/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""iad.dataman — datacast, resman, and datasets bridge (``ialdev-dataman`` distribution)."""
|
|
2
|
+
|
|
3
|
+
from .models import DataSourceRM, SchemeRM, DatasetRM, CollectionRM, DSample, discover
|
|
4
|
+
from .factories import create_caster, create_collection, create_sink
|
|
5
|
+
from .datacast.collect import DataCollection, SinkRepo
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"__version__",
|
|
11
|
+
"DataSourceRM",
|
|
12
|
+
"SchemeRM",
|
|
13
|
+
"DatasetRM",
|
|
14
|
+
"CollectionRM",
|
|
15
|
+
"DSample",
|
|
16
|
+
"discover",
|
|
17
|
+
"create_caster",
|
|
18
|
+
"create_collection",
|
|
19
|
+
"create_sink",
|
|
20
|
+
"DataCollection",
|
|
21
|
+
"SinkRepo",
|
|
22
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
""" Data Sets access and management tools."""
|
|
2
|
+
|
|
3
|
+
__all__ = ['DataCaster', 'CasterConfig', 'DataCollection', 'SinkRepo',
|
|
4
|
+
'Col', 'Fetchable', 'CollectTable', 'CollectSeries']
|
|
5
|
+
|
|
6
|
+
from .transtools import Fetchable, CollectSeries, CollectTable, Col
|
|
7
|
+
from .caster import DataCaster, CasterConfig
|
|
8
|
+
from .collect import DataCollection, SinkRepo
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Iterator, Callable, Optional, Iterable
|
|
6
|
+
|
|
7
|
+
from pydantic.v1 import PrivateAttr
|
|
8
|
+
import regex as re # Using more advanced regex package!
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from iad.core.param import YamlModel
|
|
12
|
+
from iad.core import logger, as_list
|
|
13
|
+
from iad.core.cache import CachedPipe, CacheMode, Pickle, CacheInvalidError, NoSerial
|
|
14
|
+
from iad.core.datatools import Filter
|
|
15
|
+
from iad.core.events import Timer
|
|
16
|
+
from iad.core.filesproc import Path, root_cropper, root_adder, normalize
|
|
17
|
+
from iad.core.fnctools import express_to_kw_func
|
|
18
|
+
from .scan import GuideScan
|
|
19
|
+
|
|
20
|
+
__all__ = ['DataCaster', 'CasterConfig', 'CacheMode']
|
|
21
|
+
|
|
22
|
+
_log = logger('datacast')
|
|
23
|
+
DEFAULT_MODE = CacheMode.KEEP
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CasterConfig:
|
|
27
|
+
"""Resolved configuration for a DataCaster, independent of resource models.
|
|
28
|
+
|
|
29
|
+
Stores the flat, fully-resolved parameters needed to construct a casting
|
|
30
|
+
pipeline. Supports ``.dict()`` for cloning via ``DataCaster(**cfg.dict())``.
|
|
31
|
+
"""
|
|
32
|
+
__slots__ = ('name', 'root', 'search', 'labels', 'mappings',
|
|
33
|
+
'reverse', 'bundle', 'filters', 'sample')
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, name: str = None, root: Path | str,
|
|
36
|
+
search: GuideScan, labels: dict = None,
|
|
37
|
+
mappings: dict = None, reverse: bool = False,
|
|
38
|
+
bundle: list | None = None, filters: dict = None,
|
|
39
|
+
sample: dict = None):
|
|
40
|
+
self.name = name
|
|
41
|
+
self.root = Path(root) if root else None
|
|
42
|
+
self.search = search
|
|
43
|
+
self.labels = labels or {}
|
|
44
|
+
self.mappings = mappings or {}
|
|
45
|
+
self.reverse = reverse
|
|
46
|
+
self.bundle = bundle
|
|
47
|
+
self.filters = filters
|
|
48
|
+
self.sample = sample
|
|
49
|
+
|
|
50
|
+
def dict(self) -> dict:
|
|
51
|
+
return {k: getattr(self, k) for k in self.__slots__}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def path_to_win(lbs):
|
|
55
|
+
"""Convert POSIX path into WIN"""
|
|
56
|
+
lbs['path'] = lbs['path'].replace('/', '\\')
|
|
57
|
+
return lbs
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Consider: do we really need this class, given its overlap with from SchemeRM?
|
|
61
|
+
class Labeler(YamlModel, hash_exclude=['reverse', 'categories']):
|
|
62
|
+
labels: dict
|
|
63
|
+
mappings: dict
|
|
64
|
+
reverse: bool
|
|
65
|
+
categories: Optional[list]
|
|
66
|
+
|
|
67
|
+
_pather = PrivateAttr()
|
|
68
|
+
_processor = PrivateAttr()
|
|
69
|
+
|
|
70
|
+
def __init__(self, *, labels, mappings, reverse, search: GuideScan, namespace=None, **_):
|
|
71
|
+
super().__init__(labels=labels, mappings=mappings, reverse=reverse)
|
|
72
|
+
|
|
73
|
+
self._pather = search._pather
|
|
74
|
+
cat_merger, self.categories = _create_key_merger(self._pather.regex.categories)
|
|
75
|
+
cat_mapper, reverse_mapper = _create_values_mappers(mappings, reverse)
|
|
76
|
+
cond_labels = [*map(parse_conditional_key, _to_dict(labels).items())]
|
|
77
|
+
cond_labeler = _create_labeler(cond_labels, namespace or {})
|
|
78
|
+
|
|
79
|
+
self._processor = lambda lbs: cond_labeler(cat_mapper(cat_merger(lbs)))
|
|
80
|
+
|
|
81
|
+
self._pather.mapper = reverse_mapper
|
|
82
|
+
self.categories.update(cat for _, lbs in cond_labels for cat in lbs)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def anonymous(self):
|
|
86
|
+
return tuple(self._pather.regex.anonym_groups)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def processor(self):
|
|
90
|
+
return self._processor
|
|
91
|
+
|
|
92
|
+
def path(self, root, *, no_tag: dict = None, **labels):
|
|
93
|
+
"""Create full path from given labels relative to the given root"""
|
|
94
|
+
root = normalize(root)
|
|
95
|
+
return root / self._pather(no_tag, **labels)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DataCaster:
|
|
99
|
+
"""
|
|
100
|
+
Casts data in files organized and named according to certain **layout** into
|
|
101
|
+
a generic form of *labeled data*.
|
|
102
|
+
|
|
103
|
+
See `./docs/data_casting.md`
|
|
104
|
+
|
|
105
|
+
Iterator's Processing Flow
|
|
106
|
+
---------------------------
|
|
107
|
+
::
|
|
108
|
+
|
|
109
|
+
paths ⎡ match ⎤ ⎡filter ⎤
|
|
110
|
+
folder -> [walk] -----> ⎣pattern⎦ -> ⎣matched⎦ -> [extract] -> labels ->
|
|
111
|
+
|
|
112
|
+
⎡ merge ⎤ ⎡ rename ⎤ ⎡filter by⎤ ⎡add label:⎤
|
|
113
|
+
-> ⎣categories⎦ -> ⎣categories⎦ -> ⎣condition⎦ -> ⎣transforms⎦ -> labels
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
CacheMode = CacheMode # allow PathScheme.CacheMode instead of scheme.CacheMode
|
|
117
|
+
|
|
118
|
+
def __init__(self, name: str = None, *,
|
|
119
|
+
root: Path | str,
|
|
120
|
+
search: GuideScan,
|
|
121
|
+
labels: dict = None,
|
|
122
|
+
mappings: dict = None,
|
|
123
|
+
reverse: bool = False,
|
|
124
|
+
bundle: list[str] = None,
|
|
125
|
+
filters: dict = None,
|
|
126
|
+
sample: dict = None,
|
|
127
|
+
# -- pipeline params (not part of config) --
|
|
128
|
+
temp_cache=False, cache: Optional[CacheMode | bool] = True,
|
|
129
|
+
progress: dict | bool = None):
|
|
130
|
+
"""
|
|
131
|
+
Create data caster semantic parser for particularly structured files tree.
|
|
132
|
+
::
|
|
133
|
+
tree -> [pattern] -> files -> [label] -> [filter] -> labeled
|
|
134
|
+
|
|
135
|
+
All parameters must be fully resolved (no name-based lookup).
|
|
136
|
+
For name-based construction (e.g. ``DataCaster('ETH3D')``), use the
|
|
137
|
+
factory in ``dataman`` (e.g. ``dataman.create_caster``).
|
|
138
|
+
"""
|
|
139
|
+
self.config = CasterConfig(
|
|
140
|
+
name=name, root=root, search=search, labels=labels,
|
|
141
|
+
mappings=mappings, reverse=reverse, bundle=bundle,
|
|
142
|
+
filters=filters, sample=sample,
|
|
143
|
+
)
|
|
144
|
+
labeler = Labeler(labels=self.config.labels,
|
|
145
|
+
mappings=self.config.mappings, search=self.config.search,
|
|
146
|
+
reverse=self.config.reverse, namespace=self.labeling_namespace)
|
|
147
|
+
self.categories = labeler.categories
|
|
148
|
+
self.bundle = self.config.bundle or []
|
|
149
|
+
is_win = os.name == 'nt'
|
|
150
|
+
stages = [
|
|
151
|
+
CachedPipe.Source(self.config.search.scanner(self.root, is_win), 'wlk',
|
|
152
|
+
cfg=self.config.search.to_yaml()),
|
|
153
|
+
CachedPipe.Map(labeler.processor, 'lbl', cfg=labeler.to_yaml()),
|
|
154
|
+
]
|
|
155
|
+
if self.config.filters:
|
|
156
|
+
stages.append(CachedPipe.Filter(Filter(self.config.filters), 'flt',
|
|
157
|
+
cfg=yaml.dump(self.config.filters)))
|
|
158
|
+
if is_win:
|
|
159
|
+
stages.append(CachedPipe.Map(path_to_win, 'win', serial=NoSerial(), mode=CacheMode.PASS))
|
|
160
|
+
self.cached_pipe = CachedPipe(
|
|
161
|
+
stages=stages, folder=Path(self.root) / '.cache', mode=CacheMode(cache), temp=temp_cache,
|
|
162
|
+
serial=PickleRelativePath(self.root, safe=False), progress=progress
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def labeling_namespace(self):
|
|
167
|
+
from iad.core.strings import hash_str
|
|
168
|
+
from pandas import NA, isnull
|
|
169
|
+
return dict(hash_str=hash_str, name=self.config.name, NA=NA, isnull=isnull)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def bundle(self):
|
|
173
|
+
return self._bundle
|
|
174
|
+
|
|
175
|
+
@bundle.setter
|
|
176
|
+
def bundle(self, names: str | list[str]):
|
|
177
|
+
names = as_list(names)
|
|
178
|
+
if dif := set(names).difference(self.categories):
|
|
179
|
+
KeyError(f"Bundle labels {dif} are not defined in scheme!")
|
|
180
|
+
self._bundle = names
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def root(self):
|
|
184
|
+
return self.config.root
|
|
185
|
+
|
|
186
|
+
def __repr__(self):
|
|
187
|
+
_list = lambda _: f"[{', '.join(_)}]" if _ else ""
|
|
188
|
+
info = dict(
|
|
189
|
+
root=str(self.root),
|
|
190
|
+
bundle=_list(self.bundle),
|
|
191
|
+
cats=_list(self.categories),
|
|
192
|
+
regex=self.config.search._pather.regex.regex.pattern
|
|
193
|
+
)
|
|
194
|
+
items = (f"{k}: {v}" for k, v in info.items() if v)
|
|
195
|
+
return '\n\t'.join([str(self), *items])
|
|
196
|
+
|
|
197
|
+
def __str__(self):
|
|
198
|
+
return f"<{self.__class__.__name__}: {self.config.name}>"
|
|
199
|
+
|
|
200
|
+
def __copy__(self):
|
|
201
|
+
return deepcopy(self)
|
|
202
|
+
|
|
203
|
+
def __iter__(self):
|
|
204
|
+
return self.iter()
|
|
205
|
+
|
|
206
|
+
def iter(self, *, progress: CachedPipe.ProgressT = None, ignore_sampling=False) -> Iterator:
|
|
207
|
+
"""Create iterator over files described by the scheme.
|
|
208
|
+
|
|
209
|
+
This method unlike __iter__ allows tuning default behaviours:
|
|
210
|
+
|
|
211
|
+
*Progress Bar*
|
|
212
|
+
|
|
213
|
+
Progress can be shown during the iterations if argument is:
|
|
214
|
+
- ``Callable(Iterable) -> Iterator`` like ``tqdm.tqdm``
|
|
215
|
+
- ``True`` - then ``tqdm`` is used
|
|
216
|
+
- ``dict`` with arguments for ``tqdm``
|
|
217
|
+
:param ignore_sampling: explicitly disable sampling when iterate over dataset with sample defined
|
|
218
|
+
:param progress: True, False, None (auto - show only if cache not found)
|
|
219
|
+
"""
|
|
220
|
+
if self.config.sample and not ignore_sampling:
|
|
221
|
+
raise NotImplementedError("Iterating over sampled dataset is not defined! "
|
|
222
|
+
"Use collect_table() method!")
|
|
223
|
+
if progress is not None:
|
|
224
|
+
self.cached_pipe.progress_bar(dict(delay=.8, unit='file', desc=f'{self}'))
|
|
225
|
+
return self.cached_pipe
|
|
226
|
+
|
|
227
|
+
def collect(self, progress=True):
|
|
228
|
+
"""
|
|
229
|
+
Collect dataset into the table.
|
|
230
|
+
Apply additional labels and sampling.
|
|
231
|
+
|
|
232
|
+
:param progress:
|
|
233
|
+
:return:
|
|
234
|
+
"""
|
|
235
|
+
from .transtools import CollectTable
|
|
236
|
+
ds = CollectTable(self.iter(progress=progress, ignore_sampling=True))
|
|
237
|
+
if ds.empty:
|
|
238
|
+
_log.warning(f"{self} found no matching files!")
|
|
239
|
+
|
|
240
|
+
if cfg := self.config.sample:
|
|
241
|
+
from iad.core.pdtools import sample
|
|
242
|
+
ds = sample(ds, **(cfg if isinstance(cfg, dict) else cfg.dict()))
|
|
243
|
+
return ds
|
|
244
|
+
|
|
245
|
+
def copy(self):
|
|
246
|
+
return self.__copy__()
|
|
247
|
+
|
|
248
|
+
def spellcheck(self, words: Iterable[str] = (), *, warn_thresh=85.):
|
|
249
|
+
"""Check that defined labels are not just misspelled keyword.
|
|
250
|
+
|
|
251
|
+
List of words to avoid similarity with includes:
|
|
252
|
+
- reserved words: 'description', 'pattern', 'mappings'
|
|
253
|
+
- standard labels names (defined in .transfrom.Col),
|
|
254
|
+
- and may be extended by ``words`` argument.
|
|
255
|
+
|
|
256
|
+
:param words: additional words to avoid
|
|
257
|
+
:param warn_thresh: similarity threshold
|
|
258
|
+
:return: number of found close matches
|
|
259
|
+
"""
|
|
260
|
+
from rapidfuzz.process import extractOne
|
|
261
|
+
from .transtools import Col
|
|
262
|
+
keywords = list(v for k, v in vars(Col).items() if not k.startswith('_'))
|
|
263
|
+
keywords += ['description', 'pattern', 'mappings', *words]
|
|
264
|
+
exclude = {'dataset'}
|
|
265
|
+
|
|
266
|
+
count = 0
|
|
267
|
+
for cat in self.categories:
|
|
268
|
+
closest, score, _ = extractOne(cat, keywords)
|
|
269
|
+
if warn_thresh < score < 100 and cat not in exclude:
|
|
270
|
+
count += 1
|
|
271
|
+
_log.warning("Label '%s' is %d% similar to keyword '%s' in %s.",
|
|
272
|
+
cat, score, closest, self)
|
|
273
|
+
return count
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _path_to_win(labels_gen: Iterable[dict]):
|
|
277
|
+
"""Generator converting path label into windows form"""
|
|
278
|
+
for lbs in labels_gen:
|
|
279
|
+
lbs['path'] = lbs['path'].replace('/', '\\')
|
|
280
|
+
yield lbs
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _to_dict(d: dict) -> dict:
|
|
284
|
+
"""Translate dict-like nodes of hierarchical object into pure dict.
|
|
285
|
+
|
|
286
|
+
:param d: The object, possibly a combination of dict and :class:`Box`.
|
|
287
|
+
:returns: it's modified self
|
|
288
|
+
"""
|
|
289
|
+
if hasattr(d, 'items'):
|
|
290
|
+
if hasattr(d, 'to_dict'):
|
|
291
|
+
return d.to_dict()
|
|
292
|
+
for k, v in d.items():
|
|
293
|
+
d[k] = _to_dict(v)
|
|
294
|
+
return d
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Consider: Make it part of Pather
|
|
298
|
+
def _create_key_merger(keys: Iterator, *, join: Callable[[Iterator], str] = '_'.join) \
|
|
299
|
+
-> tuple[Callable[[dict], dict], set]:
|
|
300
|
+
"""Create function for INPLACE merging dict items with enumerated keys
|
|
301
|
+
(Utility function used in parse file-name info.)
|
|
302
|
+
|
|
303
|
+
Example:
|
|
304
|
+
>>> dict(x=0, s_1='oh', s_2='my', y='ok', s_4='god') -> dict(x=0, y='ok', s='oh_my_god')
|
|
305
|
+
|
|
306
|
+
:param keys: iterable over the keys treat (usually those parsed)
|
|
307
|
+
:param join: function to glue the parts (default: "_".join(parts))
|
|
308
|
+
:returns: a function object: `func(dict) -> dict`, new labels categories
|
|
309
|
+
"""
|
|
310
|
+
dp = re.compile(r'((\w+?)_?\d+)$') # categories ending with number: scene_1
|
|
311
|
+
splits = filter(dp.fullmatch, keys) # leaves only split categories
|
|
312
|
+
splits = dict(m.groups() for m in map(dp.match, splits)) # produces {'name_id': 'name'}
|
|
313
|
+
if not splits:
|
|
314
|
+
return (lambda info: info), set(keys)
|
|
315
|
+
|
|
316
|
+
merged_keys = {k: [] for k in set(splits.values())} # group split names by their real name
|
|
317
|
+
for k, v in splits.items(): # transpose k <-> v: splits -> groups
|
|
318
|
+
merged_keys[v].append(k) # form: groups = {name: [name_0, name_1, ...]}
|
|
319
|
+
|
|
320
|
+
new_keys = set(keys).difference(splits).union(merged_keys)
|
|
321
|
+
|
|
322
|
+
def merger(info: dict):
|
|
323
|
+
for name, split_names in merged_keys.items():
|
|
324
|
+
info[name] = join(info.pop(sn) for sn in split_names)
|
|
325
|
+
return info
|
|
326
|
+
|
|
327
|
+
return merger, new_keys # Consider: return inverse to the merger - splitter (for path formatting)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# @staticmethod
|
|
331
|
+
def _create_labels_filter(filters): # Consider: Needed?
|
|
332
|
+
if not filters:
|
|
333
|
+
return {}
|
|
334
|
+
|
|
335
|
+
if isinstance(filters, str):
|
|
336
|
+
filters = {'condition': filters}
|
|
337
|
+
for k, v in filters.items():
|
|
338
|
+
if k.startswith('condition'):
|
|
339
|
+
filters[k] = express_to_kw_func(v)
|
|
340
|
+
elif isinstance(v, dict):
|
|
341
|
+
raise TypeError('Scheme label condition must be callable, sequence or scalar')
|
|
342
|
+
return filters
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
ValMapFunc = Callable[[dict], dict]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# @staticmethod
|
|
349
|
+
def _create_values_mappers(trans: dict[str, Callable | str | dict],
|
|
350
|
+
reverse: bool) -> tuple[ValMapFunc, ValMapFunc]:
|
|
351
|
+
"""Given a dictionary of label mappings create a function
|
|
352
|
+
which would translate values of its input labels dict
|
|
353
|
+
using transform with matching key if found.
|
|
354
|
+
|
|
355
|
+
Transforms could be either a dict or callables.
|
|
356
|
+
|
|
357
|
+
Return such values mapper function and reverse function, restoring the original
|
|
358
|
+
values from the transformed labels (Works correctly only for dict transforms!)
|
|
359
|
+
|
|
360
|
+
:param trans: transforms by label's category {category: transform},
|
|
361
|
+
with transform as a dict, Callable, or str evaluable into Callable.
|
|
362
|
+
:param inverse: request reverse mapping
|
|
363
|
+
:return: map_func, rev_map_func
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
def map_values(dct, *, fnc_map={}, default_fnc=lambda _: _):
|
|
367
|
+
"""
|
|
368
|
+
Map dict by applying on every value a functions selected by the corresponding key:
|
|
369
|
+
dict(key, value) -> dict(key, fnc_map[key](value))
|
|
370
|
+
|
|
371
|
+
:param dct: dictionary to convert
|
|
372
|
+
:param fnc_map: dict of functions with keys as in dct
|
|
373
|
+
:param default_fnc: function to use if key not found in fnc_map
|
|
374
|
+
"""
|
|
375
|
+
return {k: fnc_map.get(k, default_fnc)(v) for k, v in dct.items()}
|
|
376
|
+
|
|
377
|
+
def mapping_fnc(transform):
|
|
378
|
+
# converts different allowed forms of transform into functional form
|
|
379
|
+
if isinstance(transform, dict):
|
|
380
|
+
return lambda v: transform.get(v, v)
|
|
381
|
+
if isinstance(transform, str):
|
|
382
|
+
return eval(transform)
|
|
383
|
+
if isinstance(transform, Callable):
|
|
384
|
+
return transform
|
|
385
|
+
raise ValueError(f"Unsupported mapping type {type(transform)}")
|
|
386
|
+
|
|
387
|
+
def reverse_map(d):
|
|
388
|
+
"""Create reverse transform from a direct one
|
|
389
|
+
defined for a particular category in the mappings.
|
|
390
|
+
|
|
391
|
+
Notice, that from 3 types of transforms supported (see map_func)
|
|
392
|
+
only dict can be reversed (assuming no repeated values!)
|
|
393
|
+
|
|
394
|
+
So currently this function supports only that,
|
|
395
|
+
and replaces everything else by str casting.
|
|
396
|
+
|
|
397
|
+
Consider: think about real use cases
|
|
398
|
+
"""
|
|
399
|
+
return {v: k for k, v in d.items()} if isinstance(d, dict) else str
|
|
400
|
+
|
|
401
|
+
def mapper(transforms: dict) -> Callable[[dict], dict]:
|
|
402
|
+
"""Creates function to map dict of labels according to the given transformation rules.
|
|
403
|
+
|
|
404
|
+
Values which keys are missing from the ``transforms`` are passes as is!
|
|
405
|
+
"""
|
|
406
|
+
mappings = map_values(transforms, default_fnc=mapping_fnc) # make all transforms callable
|
|
407
|
+
return lambda labels: map_values(labels, fnc_map=mappings)
|
|
408
|
+
|
|
409
|
+
# reverse labels transformations rules: mapper(rev_trans)(mapper(trans)(labels)) == labels
|
|
410
|
+
reverse_mapper = mapper(map_values(trans, default_fnc=reverse_map)) if reverse else None
|
|
411
|
+
return mapper(trans), reverse_mapper
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# @staticmethod
|
|
415
|
+
def _create_labeler(cond_labels: list[tuple[Callable, dict]], namespace: dict) -> Callable[[dict], dict]:
|
|
416
|
+
"""Create function which updates provided labels with additional labels
|
|
417
|
+
if the provided labels meet certain conditions.
|
|
418
|
+
|
|
419
|
+
The argument ``cond_labels`` is a source of those conditions with associated additional labels.
|
|
420
|
+
"""
|
|
421
|
+
from types import CodeType
|
|
422
|
+
|
|
423
|
+
def add_labels(current_labels):
|
|
424
|
+
for cond_test, labels in cond_labels:
|
|
425
|
+
if cond_test(**current_labels):
|
|
426
|
+
# evaluate labels using the context provided by the current labels
|
|
427
|
+
labels = {
|
|
428
|
+
k: eval(v, namespace, current_labels)
|
|
429
|
+
if isinstance(v, CodeType) else v
|
|
430
|
+
for k, v in labels.items()
|
|
431
|
+
} # now labels are fully defined
|
|
432
|
+
current_labels |= labels # replacing some labels if they are already there
|
|
433
|
+
return current_labels
|
|
434
|
+
|
|
435
|
+
return add_labels
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def parse_conditional_key(key_item: tuple[str, dict | str]) -> tuple[Callable[[...], bool], dict]:
|
|
439
|
+
"""
|
|
440
|
+
Extract a condition from a scripted form of `key` string
|
|
441
|
+
in the given label tuple ``(key, value)``.
|
|
442
|
+
|
|
443
|
+
Return a tuple of
|
|
444
|
+
- extracted condition (if found), as a callable
|
|
445
|
+
(or universal ``true_cond`` function)
|
|
446
|
+
- pure label, at a dict {pure key: value}
|
|
447
|
+
|
|
448
|
+
`key` string templates:
|
|
449
|
+
1. ``<key>`` - unconditional key (value is the item)
|
|
450
|
+
2. ``<key> if <cond>`` - conditional key (value is the item)
|
|
451
|
+
3. ``if <cond>`` - condition only (label(s) are the item(s))
|
|
452
|
+
|
|
453
|
+
In case of 3 check that the value is a dict.
|
|
454
|
+
|
|
455
|
+
:param key_item:
|
|
456
|
+
:return: cond, label
|
|
457
|
+
"""
|
|
458
|
+
import re
|
|
459
|
+
cond_exp = re.compile(r'(\w+)?(?:\s*if (.+))?', re.IGNORECASE)
|
|
460
|
+
fstr_exp = re.compile(r'f[\'"].*[\'"]')
|
|
461
|
+
pyth_exp = re.compile(r'\b(if|else|isnull)\b|[{}()=|\'\"]') # Consider: use actual python parser instead
|
|
462
|
+
|
|
463
|
+
def compile_value(v):
|
|
464
|
+
"""Return the input as is or compile if it is a dynamic expression"""
|
|
465
|
+
return compile(v, 'scheme_dynamic', 'eval') \
|
|
466
|
+
if isinstance(v, str) and (
|
|
467
|
+
fstr_exp.fullmatch(v) or
|
|
468
|
+
pyth_exp.search(v)
|
|
469
|
+
) else v
|
|
470
|
+
|
|
471
|
+
def true_cond(**_): # always True
|
|
472
|
+
return True
|
|
473
|
+
|
|
474
|
+
key, item = key_item
|
|
475
|
+
try:
|
|
476
|
+
key, cond = cond_exp.fullmatch(key).groups()
|
|
477
|
+
except AttributeError:
|
|
478
|
+
raise SyntaxError(f'Configuration has invalid key "{key}"')
|
|
479
|
+
|
|
480
|
+
cond = express_to_kw_func(cond) if cond else true_cond # (2,3) or 1
|
|
481
|
+
|
|
482
|
+
if key:
|
|
483
|
+
label = {key: compile_value(item)} # label for cases 1, 2
|
|
484
|
+
elif isinstance(item, dict): # case 3. `if cond: {key: value}`
|
|
485
|
+
label = {k: compile_value(v) for k, v in item.items()} # item is a dict {key: value, ...}
|
|
486
|
+
else:
|
|
487
|
+
raise SyntaxError(f'Configuration under condition: {cond}\n'
|
|
488
|
+
f' expected label (a dict), but instead received:\n{item}')
|
|
489
|
+
return cond, label
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class PickleRelativePath(Pickle):
|
|
493
|
+
"""Serializer of labels used by cached pipeline to save relative paths in the cache."""
|
|
494
|
+
|
|
495
|
+
def __init__(self, root: Path | str, *, safe):
|
|
496
|
+
"""
|
|
497
|
+
:param root: relative to this folder
|
|
498
|
+
:param safe: True to use safe but slow method validating that paths indeed contain root.
|
|
499
|
+
"""
|
|
500
|
+
assert root, f'{self.__class__.__name__} definition failed, supply root'
|
|
501
|
+
self.add_root = root_adder(root, as_str=not safe, out_str=True)
|
|
502
|
+
method = 'relative' if safe else 'crop'
|
|
503
|
+
self.crop_root = root_cropper(root, method, out_str=True)
|
|
504
|
+
self._msg = f"[{method}] paths relative to {root} in {{:.2f}}sec"
|
|
505
|
+
|
|
506
|
+
def save(self, file: Path, records: list[dict]):
|
|
507
|
+
def crop_path_copy(item):
|
|
508
|
+
item = item.copy()
|
|
509
|
+
item['path'] = self.crop_root(item['path'])
|
|
510
|
+
return item
|
|
511
|
+
|
|
512
|
+
with Timer(f"Save cache {file.name} {self._msg}", _log.debug):
|
|
513
|
+
records = list(map(crop_path_copy, records))
|
|
514
|
+
super().save(file, records)
|
|
515
|
+
|
|
516
|
+
def load(self, file: Path):
|
|
517
|
+
with Timer(f"Load cache {file.name} {self._msg}", _log.debug):
|
|
518
|
+
records = super().load(file)
|
|
519
|
+
if records and Path(p := records[0]['path']).is_absolute():
|
|
520
|
+
raise CacheInvalidError(f"Cache {file.name} must contain relative paths\n[found: {p}]")
|
|
521
|
+
for item in records:
|
|
522
|
+
item['path'] = self.add_root(item['path'])
|
|
523
|
+
return records
|