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.
@@ -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