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,1466 @@
1
+ from __future__ import annotations
2
+
3
+ from iad.core.events import Timer
4
+
5
+ with Timer(f" ← {__file__} imports", "timing", min=0.1, pre=f' → importing in {__file__} ...'):
6
+ from collections import namedtuple
7
+ from pathlib import Path
8
+ from typing import Iterable, Sequence, Tuple, Callable, Union, Literal, Any, TYPE_CHECKING, Collection
9
+ import pandas as pd
10
+
11
+ # utils import
12
+ from iad.core import as_list, logger, drop_undef, as_iter
13
+ import iad.core.pdtools as pdt
14
+ from iad.core.cache import CacheMode, Cacher
15
+
16
+ # internal imports
17
+ from . import transtools as tr, CollectTable
18
+ from .transtools import Col, DataItem
19
+
20
+ if TYPE_CHECKING:
21
+ import numpy as np
22
+ from .caster import DataCaster
23
+
24
+ PathT = Union[Path, str]
25
+
26
+ StringS = pdt.StringS
27
+
28
+ _log = logger('datacast.collect')
29
+
30
+ __all__ = ['DataCollection', 'SinkRepo']
31
+
32
+
33
+ class DataCollection:
34
+ """
35
+ Container for labeled data collected potentially from multiple related datasets.
36
+
37
+ Description
38
+ ===========
39
+ Merging Datasets
40
+ ----------------
41
+ Practically only datasets sharing same `categories` should be merged
42
+ into collections. Common categories are usually used as index in the
43
+ collection's multi-level indexing structure.
44
+
45
+ Separation of categories into index and data is arbitrary, and
46
+ is completely controlled by user, but to reduce configuration
47
+ complexity some default assumptions are made:
48
+ - default data categories: `path`, `data`, `transformed`, `transforms`
49
+ - all other categories used as index
50
+ - categories without variations of values and `ext` are dropped
51
+
52
+ Accessing Collection Data Structure
53
+ -----------------------------------
54
+ `DataCollection` provides two levels of access to its data:
55
+ - low level access to `.db` <DatFrame> attribute
56
+ - several high level functions supporting specific use cases:
57
+
58
+ - smart iteration with `iter`
59
+ - query based functions: `filter`, `select`
60
+ - information attributes: `.categories`, `dataset`
61
+ - operations: `drop_non_unique`, `apply_transforms`, ...
62
+
63
+ Iterations
64
+ ~~~~~~~~~~
65
+ Method `iter` is aimed to simplify coding of the most common use case
66
+ when processing on every iteration requires access to certain
67
+ semantic context selected from the whole dataset.
68
+
69
+ It provides great flexibility by allowing to:
70
+ - iterate over groups of elements united by custom criteria
71
+ - a group structure can be specified
72
+ - default transformation applied on the fly
73
+
74
+ Data Transformation
75
+ -------------------
76
+ Some data sets include data transformation instructions
77
+ as part of their schemes in form of `transforms` and `transformed`
78
+ labels assigned to specific data items.
79
+ `transforms` (T) define set of operations to be applied on the item.
80
+ `transformed` (Td) describes operations which has been applied.
81
+
82
+ This is especially relevant to make sure data in different
83
+ items is aligned for its size, scale, range, etc.
84
+
85
+ The assumption is that `transforms` would project data into the
86
+ common _reference_ state, and `transformed` defines how the data
87
+ been transformed from this state.
88
+
89
+ The alignment procedure then includes two steps: \n
90
+ 1.. Apply all the `transforms` T on the items where they are defined \n
91
+ 2.a. Apply inverse transforms Td^(-1) where Td are defines OR \n
92
+ 2.b. Apply forward transform Td on the rest of the items
93
+
94
+ Problem of Inverse Transformations
95
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
96
+ Option 2.a is considered to be preferable, if inverse is defined,
97
+ otherwise 2.b. is applied.
98
+
99
+ In the case when both direct and inverse transform can be defined
100
+ the choice of marking `transformed` items with Td or the rest of
101
+ the items in the dataset with Td^(-1) is not quite symmetric,
102
+ as aforementioned reference state may have some natural meaning,
103
+ shared between different datasets.
104
+
105
+ Since every dataset description must be self-contained, in order
106
+ to allow merging between datasets certain semantic convention on
107
+ the data must be maintained, making option 2.a even more desirable.
108
+
109
+ If forced towards 2.b the proper approach would be to apply Td on
110
+ all the data elements in DataCollection _after_ datasets are merged.
111
+ Its not always clear which specific elements require this correction.
112
+ For example cropping can be be universally applied to all the images,
113
+ but values transformation only to the data of same *kind*.
114
+
115
+ Approach to Inverse Transformation
116
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
117
+ To avoid all the complications for step 2 it can be delegated to
118
+ the algorithms sensitive to those transformation which would access
119
+ information in the `transformed` label and act accordingly.
120
+ """
121
+
122
+ _TRANS_COL = {Col.transforms, Col.transformed}
123
+ _NO_CAT_COL = {Col.read_trans} | _TRANS_COL # non category columns
124
+
125
+ DATA_COL = (Col.path, Col.read_trans)
126
+
127
+ class _HistoryItem(namedtuple('HistoryItem',
128
+ ['oper', 'desc', 'args', 'kws', 'before', 'after'],
129
+ defaults=['', '', (), None, None, None]
130
+ )):
131
+ def __repr__(self):
132
+ from iad.core.strings import dict_str
133
+ change = "" if (self.before is None or self.after is None) \
134
+ else f" {self.before}⮕{self.after}"
135
+ desc = self.desc or ','.join(filter(None, [
136
+ ','.join(as_list(self.args)),
137
+ dict_str(self.kws)
138
+ ]))
139
+ return f"{self.oper}({desc}){change}"
140
+
141
+ class Group(namedtuple('Group', ['gid', 'grp'])):
142
+ """ Namedtuple Group(gid, grp), also can be initialized by a
143
+ tuple-like Group((gid, grp))
144
+ """
145
+
146
+ def __new__(cls, *args):
147
+ if len(args) == 2:
148
+ gid, grp = args
149
+ else:
150
+ group, gid, grp = args
151
+ if len(group) > 1:
152
+ gid = DataItem(zip(group, gid))
153
+ if not isinstance(grp, CollectTable):
154
+ grp = CollectTable(grp)
155
+ return super().__new__(cls, gid, grp)
156
+
157
+ def __repr__(self):
158
+ return f"<Group> gid: {self.gid}, grp:\n{self.grp}"
159
+
160
+ def fetch(self, **kws):
161
+ self.grp = self.grp.fetch(**kws)
162
+ return self
163
+
164
+ @property
165
+ def db(self):
166
+ return self._db
167
+
168
+ # attributes which could be inherited when modifying a collection
169
+ _inherit_attrs = ['name', 'casters', 'bundle', 'cacher']
170
+
171
+ @classmethod
172
+ def from_db(cls, db, *, like: DataCollection = None,
173
+ history: list[str | tuple] | [str | tuple] = None,
174
+ name='', casters=None, bundle=None, cacher=None):
175
+ """
176
+ Create a DataCollection object from given db and optionally other
177
+ attributes.
178
+
179
+ :param db: DataTable(Frame) object to build the collection from
180
+ :param like: DataCollection object to inherit some of its attributes
181
+ :param history: list of history records or single history record,
182
+ in which case append to the history in the `like` collection.
183
+ :param name: override that in like. Required if like is None!
184
+ :param casters: if provided override that in like
185
+ :param bundle: if provided override that in like
186
+ :param cacher: if provided override that in like
187
+ :return: a new DataCollection
188
+ """
189
+ hist_item = lambda h: isinstance(h, str) and cls._HistoryItem('stage', h) or cls._HistoryItem(*h)
190
+
191
+ if like is None and not isinstance(name, str):
192
+ raise ValueError("Provide either name or like")
193
+ casters = casters or []
194
+ bundle = as_list(bundle) or []
195
+
196
+ dc = cls.__new__(cls)
197
+ # TODO insert here handling more than db
198
+ dc._db = db if isinstance(db, CollectTable) else CollectTable(db)
199
+
200
+ if isinstance(history, (str, tuple)):
201
+ history = hist_item(history)
202
+ dc._history = like and like._history + [history] or [history]
203
+ elif isinstance(history, list):
204
+ dc._history = list(map(hist_item, history))
205
+ elif not history:
206
+ dc._history = like and like._history or []
207
+ else:
208
+ raise TypeError("Invalid history argument")
209
+
210
+ lc = locals()
211
+ for attr in cls._inherit_attrs:
212
+ if not (val := lc[attr]) and like:
213
+ val = getattr(like, attr)
214
+ setattr(dc, attr, val)
215
+ dc.cacher is True and dc._create_cacher(True)
216
+ return dc
217
+
218
+ def __init__(self, name: str = None, *,
219
+ datasets: Union[DataCaster, Iterable[DataCaster]] = None,
220
+ query: str | dict | Tuple[str, str | dict] = None,
221
+ bundle: list[str] = None,
222
+ unique=False,
223
+ data: str | Iterable[str] = DATA_COL,
224
+ drop: str | Iterable[str] = None,
225
+ cache: CacheMode | bool | Literal['LOAD', 'SAVE', 'KEEP', 'PASS'] = None,
226
+ temp_cache: bool | Path | str = None,
227
+ calc_cache: PathT | Cacher | bool = False,
228
+ calc_cache_rel: PathT | bool = True,
229
+ progress: bool = False,
230
+ description: str = None):
231
+ """ Create collection of labeled paths from multiple schemes
232
+ extracting labels from data's folders structure.
233
+
234
+ Requires explicit ``datasets`` (list of DataCaster instances).
235
+ For name-based construction (e.g. ``DataCollection('KITTI')``), use
236
+ the factory in ``dataman`` (e.g. ``dataman.create_collection``).
237
+
238
+ :param name: optional name identifying the collection
239
+ :param datasets: DataCaster instances to collect
240
+ :param query: optional query to select only a subset of the collection
241
+ in str or dict form
242
+ :param data: which labels categories to consider as data (others - index)
243
+ :param unique: leave only labels with multiple unique values
244
+ :param drop: try to drop those categories if that keeps the index unique
245
+ :param cache: optionally override caster's caching mode (``None`` - don't)
246
+ :param temp_cache: optionally override caster's ``temp`` argument (``None`` - don't)
247
+ :param calc_cache: path to cache folder, Cacher, True for auto, False to disable
248
+ :param calc_cache_rel: store cached paths relative to this folder
249
+ :param description: String that describes the collection.
250
+ """
251
+ from .caster import DataCaster
252
+ if datasets is None:
253
+ raise ValueError("DataCollection requires explicit 'datasets' argument. "
254
+ "For name-based lookup use dataman.create_collection().")
255
+ if isinstance(datasets, DataCaster):
256
+ datasets = [datasets]
257
+ changes = drop_undef(cache=cache, temp_cache=temp_cache)
258
+
259
+ def prep_caster(ds):
260
+ if isinstance(ds, DataCaster):
261
+ return DataCaster(**(ds.config.dict() | changes)) if changes else ds
262
+ if isinstance(ds, dict):
263
+ return DataCaster(**ds, **changes)
264
+ raise TypeError(f"Expected DataCaster or dict, got {type(ds)}")
265
+
266
+ self.casters = [prep_caster(d) for d in as_iter(datasets, no_iter=(DataCaster,))]
267
+ if not self.casters:
268
+ raise ValueError("Can't create collection without datasets")
269
+ from pandas import concat
270
+ db = CollectTable(concat(cst.collect(progress=progress) for cst in self.casters))
271
+ self.name = name or ','.join(filter(None, (c.config.name for c in self.casters)))
272
+ self.description = description
273
+ self._history: list[DataCollection._HistoryItem] = []
274
+ bundle = as_list(bundle) or sum(
275
+ filter(None, map(lambda s: s.bundle, self.casters)), []
276
+ )
277
+ self.bundle = list(set(bundle))
278
+ if db.empty:
279
+ self._db = db
280
+ return
281
+ sq, dq = ('', query) if isinstance(query, dict) else (query, {})
282
+ db: CollectTable = self._select(db, sq, **dq)
283
+ if unique:
284
+ db = db.drop(columns=db.columns[(db.nunique(dropna=False) == 1)])
285
+ data: set = as_list(data, collect=set)
286
+ db = self._init_transforms(db, data)
287
+ self._db = self._init_index(db, data, drop)
288
+ self.cacher: Cacher = self._create_cacher(calc_cache, calc_cache_rel)
289
+
290
+ def drop_flat_categories(self, *drop, keep=None, only_nan=False, fail=False,
291
+ keep_bundle: bool | str | Collection[str] = True):
292
+ """
293
+ Remove uninformative categories from the index.
294
+
295
+ - If `drop` is provided, select only remove from those.
296
+ - Categories in `keep` are excluded from the drop.
297
+
298
+ :param drop: if provided, only categories from this list are verified
299
+ :param keep: exclude from verification
300
+ :param only_nan: if `True`, drop only if all category values are `None`
301
+ :param keep_bundle: if `True`, *bundle* categories are excluded,
302
+ otherwise bundle may be reduced. by dropped categories,
303
+ unless *all* the bundle is 'flat' in this case it is kept.
304
+ :param fail: raise `ValueError` on errors or just return without changes
305
+ :return:
306
+ """
307
+
308
+ def _exit(msg):
309
+ _log.error(msg)
310
+ if fail:
311
+ raise ValueError(msg)
312
+
313
+ db = self._db
314
+ all_cats = set(db.index.names)
315
+ drop, keep = (as_list(_, collect=set) for _ in (drop or all_cats, keep))
316
+
317
+ if isinstance(keep_bundle, bool):
318
+ keep_bundle = self.bundle if keep_bundle else []
319
+ else:
320
+ keep_bundle = as_list(keep_bundle, collect=set)
321
+ if invalid := keep_bundle.difference(self.bundle):
322
+ return _exit(f"Invalid bundle categories {invalid}")
323
+ keep = keep.union(keep_bundle)
324
+
325
+ cats = all_cats.difference(keep).intersection(drop)
326
+
327
+ if (sz := len(db)) < 2:
328
+ return _exit(f"Can't determine flat categories in collection with {sz} rows")
329
+
330
+ drop_cats = [name for name in cats if (
331
+ (unq := db.index.unique(name)).size == 1
332
+ and (not only_nan or pd.isna(unq[0]))
333
+ )]
334
+
335
+ if not drop_cats:
336
+ return
337
+ elif len(drop_cats) == len(all_cats):
338
+ return _exit("Can't drop ALL the index levels!")
339
+
340
+ bundle = set(self.bundle).difference(drop_cats)
341
+ if not bundle and self.bundle:
342
+ return _exit("Dropping categories eliminates the bundle, "
343
+ "consider constraining with keep_bundle")
344
+
345
+ self._db = db.droplevel(list(drop_cats))
346
+ self.bundle = list(bundle)
347
+
348
+ def _init_index(self, db, data: set, drop):
349
+ """
350
+ Move categories (columns) into index, except of:
351
+ - explicitly defined as `data`
352
+ - columns containing any object type except of ``bool``, ``str``
353
+ """
354
+ def _dtype_has_object_items(series):
355
+ dt = series.dtype
356
+ if getattr(dt, 'hasobject', False):
357
+ return True
358
+ from pandas.api.types import is_object_dtype
359
+ return is_object_dtype(series)
360
+
361
+ data = data.intersection(db.columns) | {c for c, s in db.items() if _dtype_has_object_items(s) and
362
+ not pdt.series_has_types(db[c], (str, bool))}
363
+ drop: set = as_list(drop, collect=set)
364
+ db.drop(columns=list(drop & data), inplace=True) # drop requested if they are not in data
365
+ if index_labels := list(db.columns.drop(data)): # all the non-data labels
366
+ db.set_index(index_labels, inplace=True) # create index
367
+ if db.index.unique().size != db.index.size:
368
+ raise IndexError("Collection index is not unique, invalid scheme?")
369
+ db = self.drop_non_unique(db, drop, inplace=True) # drop requested index levels only if unique
370
+ return db
371
+
372
+ def _init_transforms(self, db, data: set):
373
+ if Col.read_trans in data:
374
+ db[Col.read_trans] = tr.compile_read_transforms(db, ns=tr.transforms())
375
+ db.drop(columns=list(self._TRANS_COL - data), errors='ignore', inplace=True)
376
+ return db
377
+
378
+ def _create_cacher(self, cache: Cacher | PathT | bool, cache_rel: PathT = True) -> Cacher:
379
+ """ Create cacher object from different forms of definitions
380
+ (in case of Cacher input return it ignoring other arguments).
381
+
382
+ :param cache: path to cache folder or Cacher object
383
+ - False to disable cacher
384
+ - True for automatic path (self.common_root / .cache)
385
+ :param cache_rel: store cached paths tables relative to this folder
386
+ which can be provided explicitly or:
387
+ - False | None | '' - store absolute
388
+ - True | 'common' - use this self.common_root folder
389
+ Ignored if cache is Cacher object
390
+ :return: created Cacher object
391
+ """
392
+ from iad.core.cache import CacheMode, Cacher
393
+ if isinstance(cache, Cacher):
394
+ return cache
395
+
396
+ if cache_rel in (True, 'common'):
397
+ cache_rel = self.common_root
398
+
399
+ mode = CacheMode.PASS
400
+ if cache:
401
+ mode = CacheMode.KEEP
402
+ if isinstance(cache, bool):
403
+ cache = self.common_root / '.cache'
404
+ else:
405
+ cache = Path(cache).expanduser().absolute()
406
+ cache.mkdir(parents=True, exist_ok=True) # make sure cache folder is valid
407
+ _log.debug(f'Caching folder for {self.__class__.__name__} "{self.name}": {str(cache)}')
408
+
409
+ return Cacher(folder=cache, mode=mode,
410
+ pack=pdt.path_fixer(cache_rel, 'crop'),
411
+ unpack=pdt.path_fixer(cache_rel, 'add'),
412
+ load=pdt.pd.read_pickle, save=lambda f, df: df.to_pickle(f))
413
+
414
+ def measure_data(self, msr_fnc: Callable[[...], dict | Sequence], *, df: pd.DataFrame = None,
415
+ pos: StringS = Col.data, kwcol: StringS = None,
416
+ alias: dict[str, str] = None, out: StringS = None,
417
+ cache: Cacher | bool | dict = False, parallel: pdt.Parallel | str | bool = False,
418
+ direct: bool = False, reindex: bool = True, args: tuple = (), **kws) -> pd.DataFrame:
419
+ """
420
+ Perform measurements on the internal (default) or provided DataFrame.
421
+
422
+ Measurements are per row, and are defined by the provided ``msr_fnc`` which MUST
423
+ - receive `at least one positional argument` (data), and
424
+ - produce results in either named (dict-like) or not form (list-like).
425
+
426
+ ``out`` argument may be used provide (or override) names of the results,
427
+ which defines also the names of the resulting columns in the returned DataFrame.
428
+
429
+ Additional arguments of ``msr_fnc`` may come either from columns,
430
+ or are common for all the rows (``args`` and ``kws`` arguments).
431
+
432
+ In current implementation to use index levels as inputs, they should be first reset into columns.
433
+
434
+ ``pos``, ``kwcol`` and ``alias`` arguments relate columns names with ``msr_fnc`` arguments,
435
+ and MUST include `data` column in one of them.
436
+
437
+ Caching of the calculations results is configured by `cache` argument.
438
+ Since index is not used, it's not cached either, and is attached to the columns
439
+ loaded from the cache.
440
+
441
+ Parallelization control follows ``pdtools.Parallel.from_flag()``
442
+
443
+ :param msr_fnc: function to
444
+ :param df: if provided used instead of the internal db
445
+ :param pos: name[s] of the columns to be used as msr_fnc positional arguments
446
+ :param kwcol: name[s] of the columns same as msr_fnc keyword arguments
447
+ :param alias: mapping of columns names into the corresponding msr_fnc names
448
+ :param out: names of the resulting columns
449
+ :param cache: a Cacher object | True to use default | dict to update default's parameters
450
+ str as shortcut {'name': str} | False or None to disable
451
+ :param parallel: 'swift'|'jobs'|int|True|False|None
452
+ :param direct: directly perform pre-built transformations
453
+ :param reindex: reindex the resulting frame to source index
454
+ :param args: additional positional arguments passed into msr_fnc
455
+ :param kws: additional keyword arguments passed into msr_fnc
456
+ :return:
457
+ """
458
+ # --- Pre-process Arguments
459
+ pos, kwcol, out = map(as_list, [pos, kwcol, out])
460
+ alias = alias or {}
461
+
462
+ df = df if df is not None else self.db
463
+ engine = pdt.Parallel.from_flag(parallel) or type(df) # parallel or DataFrame apply-engine
464
+
465
+ def cache_config():
466
+ """return dict with caching parameters:"""
467
+
468
+ def auto_name(name=''):
469
+ db_hash = self.hash_str(columns=sorted(use_col), index=False)
470
+ return '_'.join(filter(bool, [getattr(msr_fnc, '__name__', ''), name, db_hash]))
471
+
472
+ if not cache:
473
+ return {'mode': False} # switch cacher OFF
474
+ if isinstance(cache, dict):
475
+ return cache | {'name': cache.get('name', auto_name())} # update cacher
476
+ if isinstance(cache, (bool, str)):
477
+ return {'name': auto_name('' if cache is True else cache)}
478
+ raise TypeError(f"Unexpected {type(cache)=}")
479
+
480
+ def req_trans():
481
+ """Find transform columns required to produce missing columns or raise if impossible!
482
+ Return name of the column with transform and set of columns used by the measurement
483
+ """
484
+ avl_cols = [*df.columns] # columns available for measurement function
485
+ req_cols = {*pos, *kwcol, *alias}
486
+ if missing := req_cols.difference(avl_cols): # from required by the mappings
487
+ trans_inp, trans_out = {Col.path, Col.read_trans}, {Col.data} # supported transform
488
+ if missing == trans_out and trans_inp.issubset(avl_cols): # transform can provide missing
489
+ return Col.read_trans, (req_cols - trans_out).union(trans_inp)
490
+ raise LookupError(f"Missing columns defined as function arguments: {missing}.")
491
+ return None, req_cols # not needed
492
+
493
+ if not direct:
494
+ # --- Construct measurement function: first by-row version, then prepend transform, then apply
495
+ trans, use_col = req_trans()
496
+ row_fnc = pdt.col_args_row_func(msr_fnc, pos=pos, kwcol=kwcol, alias=alias, out=out)
497
+ row_fnc = tr.row_func_prepend_transform(row_fnc, trans=trans) # does nothing if not trans
498
+ else:
499
+ row_fnc = tr.row_func_prepend_transform(msr_fnc) # use pre-built transformations
500
+
501
+ if not isinstance(cache, Cacher):
502
+ cache = self.cacher.context(**cache_config())
503
+
504
+ @cache # make measurement cachable if 'mode' is active
505
+ def measure(_df, _args, **_kws): # expose arguments caching must be sensitive to
506
+ return engine.apply(_df, row_fnc, axis=1, result_type='expand', args=_args, **_kws)
507
+
508
+ return measure(df.reset_index(), args, **kws).set_index(df.index) if reindex \
509
+ else measure(df.reset_index(), args, **kws)
510
+
511
+ def changelog(self, oper='', desc='', args=(), kws=None, before=None, after=None):
512
+ """Add to the changelog of the data collection.
513
+ if exactly one of `before` or `after` is provided, the other is taken from the current state.
514
+
515
+ :param oper: name of the operation led to the change
516
+ :param desc: description of the operation, could be parameters
517
+ :param args: arguments the operation function was called with used
518
+ :param kws: keyword arguments the operation function was called with used
519
+ """
520
+ if before is None and after is not None:
521
+ before = len(self._db)
522
+ elif before is not None and after is None:
523
+ after = len(self._db)
524
+ self._history.append(self._HistoryItem(oper, desc, args, kws or {}, before, after))
525
+
526
+ def copy(self):
527
+ """Return a copy of the object , deep if requested"""
528
+ return self.from_db(self.db, like=self)
529
+
530
+ @property
531
+ def caster(self):
532
+ """Caster in the base of the collection or None if multiple"""
533
+ return len(self.casters) == 1 and self.casters[0] or None
534
+
535
+ @property
536
+ def common_root(self) -> Path:
537
+ """Common root of all the schemes in the collection."""
538
+ if len(self.casters) == 1:
539
+ common = self.casters[0].root
540
+ else:
541
+ from os.path import commonpath
542
+ common = commonpath(s.root for s in self.casters)
543
+ return Path(common)
544
+
545
+ @property
546
+ def categories(self):
547
+ """Set of categories, in both index and data sections."""
548
+ return set(self.db.columns).union(filter(None, self.db.index.names)) - self._NO_CAT_COL
549
+
550
+ def category(self, name):
551
+ return self.db.index.get_level_values(name).unique()
552
+
553
+ def index_info(self, *, width=70, out=None):
554
+ """
555
+ Collect and return info about the database index in different forms
556
+ :param out: None - print, str - same as string, dict - {level: unique values}
557
+ :param width: line width in characters
558
+ :return: one of the formats or None if `out` is None
559
+ """
560
+ import pandas as pd
561
+
562
+ def repr_seq(total, seq):
563
+ if isinstance(seq, str): return seq
564
+ sep_len = len(sep := ', ')
565
+ res = ''
566
+ for s in map(str, seq):
567
+ if len(s) + len(res) + sep_len < total:
568
+ res = res and f"{res}{sep}{s}" or s
569
+ else:
570
+ res = res + f'… [{len(seq)}]'
571
+ break
572
+ return res
573
+
574
+ index = self.db.index
575
+ levels = {
576
+ lvl or '∅': f"Range[{index.start}:{index.stop}:{index.step}]"
577
+ if isinstance(index, pd.RangeIndex) else
578
+ index.unique(lvl) for lvl in index.names
579
+ }
580
+ if out is dict:
581
+ return levels
582
+
583
+ names_width = max(map(len, levels))
584
+ fmt = f"{{:>2}}:{{:<{names_width}}} : {{}}"
585
+ width -= (names_width + len(fmt.format(0, '', '')))
586
+ info = '\n'.join(fmt.format(lid, lvl, repr_seq(width, vals))
587
+ for lid, (lvl, vals) in enumerate(levels.items()))
588
+ return info if out == str else print(info)
589
+
590
+ def __len__(self):
591
+ return len(self.db)
592
+
593
+ def __str__(self):
594
+ return f"📚Collection '{self.name}' [{len(self)}] ({len(self.casters)} schemes)"
595
+
596
+ def __repr__(self):
597
+ if self.empty:
598
+ return f"📚Collection '{self.name}' is EMPTY!"
599
+ bundle = set(self.bundle)
600
+ mark_bundle = lambda c: c in bundle and f"✓{c}" or c
601
+ index_cats = ','.join(mark_bundle(c) for c in self.db.index.names if c)
602
+ data_cats = ','.join(self.db.columns)
603
+
604
+ hsep = ' ▷ '
605
+ hpfx = "➿ Altered: "
606
+ history = hsep.join(map(str, self._history))
607
+ if len(self._history):
608
+ if len(history) > 70:
609
+ history = ('\n ' + hsep).join([hpfx, *map(str, self._history)]) + '\n'
610
+ else:
611
+ history = hpfx + history + '\n'
612
+
613
+ return f"{str(self)}\n" \
614
+ f"{history}" \
615
+ f"🔖 Index[{index_cats}] ⮕ Data[{data_cats}]" \
616
+ f"\n{self.index_info(width=80, out=str)}"
617
+
618
+ def keep_levels(self, level: str | Sequence[str], *, strict=True):
619
+ """
620
+ **Change internal** ``.db`` by leaving only given level(s) in the *given order*
621
+ in the axis 0 index levels.
622
+
623
+ :param level: name or sequence of names of levels
624
+ :param strict: if True raise when unknown level is provided.
625
+ otherwise just ignore it.
626
+ :param: return self with updated db.
627
+ """
628
+ self._db = self._db.keep_levels(level, strict=strict)
629
+ return self
630
+
631
+ @staticmethod # Consider: move to pdtools
632
+ def drop_non_unique(src: pdt.DTable, drop: str | Iterable[str], *, inplace=True):
633
+ """Try to drop categories in drop if that keeps index unique.
634
+ :param src: data frame
635
+ :param drop: categories to drop if all the values are not unique
636
+ :param inplace: if True operate on the input collection db
637
+ :return: updated collection
638
+ """
639
+ trg = src if inplace else src.copy(True)
640
+ for cat in as_list(drop):
641
+ if cat in trg.index.names:
642
+ new_idx = trg.index.droplevel(cat)
643
+ if len(new_idx.unique()) == len(new_idx):
644
+ trg.index = new_idx
645
+ return trg
646
+
647
+ @staticmethod
648
+ def _select(df: pd.DataFrame, query: str = None, **kws) -> pd.DataFrame:
649
+ """ Select from the given DataFrame a subset according to a query in
650
+ string or dict form.
651
+ :param df: DataFrame
652
+ :param query: str may include an arbitrary python expression supported by
653
+ DataFrame.query() in terms of the categories of the labels:
654
+ ``"cat1 == value1 or cat2 * 2 < value"``
655
+ :param kws: query in category=value pairs selects data
656
+ where ``cat1 == value1 and cat2 == value and ...``
657
+ Either kws OR string query may be provided
658
+ :return: resulted selected as a DataFrame
659
+ """
660
+ if kws and not query:
661
+ query = kws
662
+ elif query and kws:
663
+ raise ValueError("Both query AND keywords arguments are provided!")
664
+ if not query:
665
+ return df
666
+ if isinstance(query, dict):
667
+ query = _dict_to_query(query)
668
+ if not isinstance(query, str):
669
+ raise TypeError('Query must be a string!')
670
+ return df.query(query, engine='python')
671
+
672
+ def sample(self, selection: slice | int | float,
673
+ comment: str = None, shuffle=False, bundle=True, inplace=False):
674
+ """
675
+ Sample the db using the slice.
676
+
677
+ :param selection: slice to use (slc:int means [0:scl])
678
+ :param comment: describe this operation for logging
679
+ :param shuffle: if True - random shuffle before sampling
680
+ :param bundle: list of index levels to group and sample from groups.
681
+ ``True`` - use `self.bundle`.
682
+ ``False`` - sampling rows.
683
+ :param inplace: change the db itself
684
+ :return: changed object or copy
685
+ """
686
+ bundle = self.bundle if bundle is True else bundle
687
+ bundle_info = "" if not bundle else bundle == self.bundle and "<bundle>" or bundle
688
+
689
+ db = pdt.sample(self._db, selection=selection, shuffle=shuffle, groups=bundle)
690
+ before, after = len(self._db), len(db)
691
+ if before == after:
692
+ return self
693
+
694
+ comment = comment or f'[{selection}]{shuffle and " Shuffle" or ""} {bundle_info}'
695
+ history = self._history + [self._HistoryItem('sample', comment, (selection,), {}, before, after)]
696
+ if inplace:
697
+ self._db = db
698
+ self._history = history
699
+ return self
700
+ return type(self).from_db(db, history=history, like=self)
701
+
702
+ # Consider: rename to query?
703
+ def select(self, query: str = None, **kws) -> pd.DataFrame:
704
+ """ Select from the internal db.
705
+ :param query: str may include an arbitrary python expression supported by
706
+ DataFrame.query() in terms of the categories of the labels:
707
+ ``"cat1 == value1 or cat2 * 2 < value"``
708
+ :param kws: query in category=value pairs selects data
709
+ where ``cat1 == value1 and cat2 == value and ...``
710
+ Either kws OR string query may be provided
711
+ :return: resulted selected as a DataFrame
712
+ """
713
+ return self._select(self.db, query, **kws)
714
+
715
+ # Consider: why both filter and select?
716
+ def filter(self, query: str = None, *, comment: str = None, inplace=False, **kws):
717
+ """ Select from the internal db.
718
+ :param query: str may include an arbitrary python expression supported by
719
+ DataFrame.query() in terms of the categories of the labels:
720
+ ``"cat1 == value1 or cat2 * 2 < value"``
721
+ :param comment: description of the meaning of this filter
722
+ :param inplace: if True change own data-base and return self
723
+ :param kws: query in category=value pairs selects data
724
+ where ``cat1 == value1 and cat2 == value and ...``
725
+ Either kws OR string query may be provided
726
+ :return: DataCollection Filtered
727
+ """
728
+ trg = self if inplace else self.copy()
729
+ if not (query or kws):
730
+ return trg
731
+
732
+ args = ((query,), kws)
733
+ if kws and not query:
734
+ query, kws = _dict_to_query(kws), {}
735
+
736
+ db = trg.select(query=query, **kws)
737
+ before, after = len(trg._db), len(db)
738
+ if before != after:
739
+ trg.changelog('filter', str(comment or query), *args, before, after)
740
+ trg._db = db
741
+ return trg
742
+
743
+ def qix(self, *args, drop_level: bool = False, trans=False,
744
+ axis=None, key_err=True, as_dc=False, **kws) -> CollectTable | DataCollection:
745
+ """Fuzzy query of data collection index and return either filtered
746
+ version of the data collection (as_dc argument) or
747
+ filtered `db` table, optionally in *transformed* formed (data loaded).
748
+
749
+ Different query types are supported:
750
+ - by
751
+
752
+ :param args: list of values from one of the index levels
753
+ - will try all until first is found or raise KeyError
754
+ :param drop_level: if True - drop found levels from the result
755
+ :param trans: if True apply data transforms when producing results
756
+ :param axis: if specified query only this axis
757
+ :param key_err: if False ignore key errors
758
+ :param as_dc: results are returned as a new DataCollection
759
+ :param kws: {level: value} - eliminates exhaustive search in all levels
760
+
761
+ :return: Transformed selection's data as DataSeries object.
762
+ """
763
+ import pandas as pd
764
+ # support of usage of direct index instead of smart queries
765
+ if trans and as_dc:
766
+ raise ValueError("Transforms can not be applied if producing data collection")
767
+
768
+ # a single unnamed argument could be an index or list of indices
769
+ if not kws and len(args) == 1 and isinstance(args[0], (list, tuple, pd.Index)):
770
+ if isinstance(idx := args[0], tuple):
771
+ idx = list(idx) if isinstance(idx[0], tuple) else [idx]
772
+ sub = self.db.loc[idx]
773
+ if len(sub) != (len(idx) if isinstance(idx, list) else 1):
774
+ raise IndexError("Invalid index passed to qix")
775
+ else: # query based indexing
776
+ sub = self.db.qix(*args, drop_level=drop_level, axis=axis, key_err=key_err, **kws)
777
+
778
+ if trans:
779
+ import pandas as pd
780
+ sub = tr.apply_column_transform(sub)
781
+ if isinstance(sub, pd.DataFrame):
782
+ sub = sub[Col.data]
783
+
784
+ if not as_dc:
785
+ return sub
786
+
787
+ # ---- prepare new DataCollection parameters ----
788
+ before, after = len(self.db), len(sub)
789
+ if before == after:
790
+ return self
791
+
792
+ if bundle := self.bundle:
793
+ bundle = [*filter(sub.index.names.__contains__, self.bundle)]
794
+
795
+ from iad.core.strings import dict_str
796
+ join = lambda seq: seq and [','.join(map(str, seq))] or []
797
+ desc = join(join(args) + (kws and [dict_str(kws)] or []))[0]
798
+ return type(self).from_db(sub, like=self, bundle=bundle,
799
+ history=('qix', desc, args, kws, before, after),
800
+ name=isinstance(as_dc, str) and as_dc or self.name)
801
+
802
+ def fetch(self, labels: Sequence[dict[str, Any]], *, levels=None, first_found=False):
803
+ return self._db.select(labels, levels=levels, first_found=first_found).fetch()
804
+
805
+ def add(self, other: pd.DataFrame | DataCollection, *,
806
+ other_name='DataFrame', desc=None, inplace=False, **kws):
807
+ """Add another data collection or bare db DataFrame
808
+ to append to this one or form a new.
809
+
810
+ :param other: a dc or db to add
811
+ :param other_name: used if other is DataFrame
812
+ :param desc: to use in changelog
813
+ :param inplace: if True update this
814
+ :return: new DC of this updated
815
+ """
816
+ import pandas as pd
817
+ # when other is None or empty
818
+ if other is None or (isinstance(other, DataCollection) and other.empty):
819
+ return self
820
+ assert isinstance(other, (DataCollection, pd.DataFrame))
821
+ is_dc = isinstance(other, DataCollection)
822
+ assert is_dc or isinstance(other, pd.DataFrame)
823
+ # when self is empty but other have new data
824
+ if self.empty:
825
+ return other if is_dc else self.from_db(other, like=self)
826
+ odb, other_name = (other.db, other.name) if is_dc else (other, other_name)
827
+ name = f"{self.name}+{other_name}"
828
+
829
+ db = pd.concat([self.db.reset_index(), odb.reset_index()])
830
+ idx1, idx2 = self.db.index.names, odb.index.names
831
+ db.set_index(idx1.union(idx2.difference(idx1)), inplace=True)
832
+ before = len(self), len(odb)
833
+
834
+ if is_dc:
835
+ def rename_hist(obj):
836
+ rename_item = lambda h: h._replace(oper=f"<{obj.name}>{h.oper}")
837
+ return [*map(rename_item, obj._history)]
838
+
839
+ upd = dict(
840
+ name=name,
841
+ casters=self.casters + other.casters,
842
+ bundle=list(set(self.bundle + other.bundle)),
843
+ history=sum(map(rename_hist, [self, other]), []),
844
+ cacher=self.cacher or other.cacher or None
845
+ )
846
+ kws.update({k: v for k, v in upd.items() if k not in kws})
847
+ desc = desc or kws.get('name', name)
848
+
849
+ if inplace:
850
+ dc = self
851
+ self._db = db
852
+ any(setattr(self, k, v) for k, v in kws.items() if k != 'cacher')
853
+ else:
854
+ dc = type(self).from_db(db, like=self, **kws)
855
+ dc.changelog('Add', desc, before=before, after=len(db))
856
+ return dc
857
+
858
+ def __iadd__(self, other):
859
+ return self.add(other, desc='+=', inplace=True)
860
+
861
+ def __add__(self, other):
862
+ return self.add(other, desc='+')
863
+
864
+ @classmethod
865
+ def mix(cls, col_sel: Iterable[tuple[DataCollection, Union[float, int, tuple]]],
866
+ shuffle=True, bundle: tuple[list[str]] | list[str] | bool = True) -> DataCollection:
867
+ """
868
+ Returning new DataCollection with a sample of each input dc with its correspondent proportion.
869
+
870
+ :param col_sel: iterable over tuples: (data collections, sampling params), (or `dict` of such)
871
+ :param shuffle: if True - random shuffle before sampling for each dc.
872
+ :param bundle: The bundle for the sampling
873
+ - ``True`` to each use its own `dc.bundle`, or list of bundles per
874
+ or user defined bundle for each dc
875
+ - ``list[str]`` bundle - for a mutual bundle for all
876
+ None - sampling rows
877
+ :return: DataCollection with mix of collections.
878
+ """
879
+ if isinstance(col_sel, dict):
880
+ col_sel = col_sel.items()
881
+ if not isinstance(bundle, list):
882
+ from itertools import repeat
883
+ bundle = repeat(bundle)
884
+
885
+ return sum(
886
+ (dc.sample(selection=prop, shuffle=shuffle, bundle=bnd)
887
+ for (dc, prop), bnd in zip(col_sel, bundle)),
888
+ cls.from_db(CollectTable())
889
+ )
890
+
891
+ def __getitem__(self, req: int | slice | dict):
892
+ if isinstance(req, int) or isinstance(req, slice):
893
+ return self.db.iloc[req]
894
+ if isinstance(req, dict):
895
+ records = self.select(**req).to_dict(orient='records')
896
+ if not len(records):
897
+ raise KeyError(f'Items with key {req} not found in {self.__class__}')
898
+ return records if len(records) > 1 else records[0]
899
+ raise TypeError(f'Unsupported key type {type(req)}!')
900
+
901
+ def __iter__(self):
902
+ return self.db.iterrows()
903
+
904
+ def apply(self, *trans, inp=Col.data, out=Col.data):
905
+ """Apply sequence of transformations to the dataset columns
906
+ :param trans: sequence of transformation stage instructions:
907
+ `(inp, fnc, out)` or `(inp, fnc)` or `fnc`
908
+ :param inp: default inp column for the first stage
909
+ :param out: default output column of any stage if out is missing
910
+ """
911
+ from .transtools import apply_to
912
+ apply_to(self.db, *trans, inp=inp, out=out)
913
+
914
+ def groups_num(self, group: str | list[str] = None, **kws):
915
+ """Count number of specific groups in the database.
916
+
917
+ :param group: categories to group by
918
+ :param kws: other DataFrame.groupby arguments
919
+ """
920
+ if not (group := group or self.bundle):
921
+ raise ValueError("Neither group nor bundle is defined")
922
+ return self.db.groupby(as_list(group), **kws).ngroups
923
+
924
+ class Iter:
925
+ def __init__(self, iterator, tuples: bool, size=NotImplemented):
926
+ self.iter = iterator
927
+ self.tuples = tuples
928
+ self.size = size
929
+
930
+ def __iter__(self):
931
+ return self.iter.__iter__()
932
+
933
+ def __next__(self):
934
+ return self.iter.__next__()
935
+
936
+ def __length_hint__(self):
937
+ return self.size
938
+
939
+ def __mod__(self, filter_func: Callable):
940
+ return self.__class__(
941
+ (it for it in self.iter if filter_func(*(
942
+ it if self.tuples else (it,)
943
+ ))),
944
+ tuples=self.tuples)
945
+
946
+ def __truediv__(self, apply_func: Callable):
947
+ return self.__class__(
948
+ (it.__class__(it.gid, apply_func(it.grp)) for it in self.iter)
949
+ if self.tuples else
950
+ (it.__class__(apply_func(it)) for it in self.iter),
951
+ tuples=self.tuples,
952
+ size=self.size)
953
+
954
+ def iter(self, group: str | Sequence[str] = None, *,
955
+ index: str | Sequence[str] = None,
956
+ data: str | Sequence[str] = None, gid=True,
957
+ trans: bool | str | Tuple[str, str] = False,
958
+ islice=None, shuffle=False,
959
+ out: str = None, progress: bool | dict = None) -> Iter:
960
+ """
961
+ Wraps `algutils.pdtools.group_iter` to wrok on the internal
962
+ DataFrame (self.db), also can transform and pivot the groups.
963
+
964
+ Docs from `algutils.pdtools.group_iter`:
965
+ -----------------------------------------
966
+ Create iterator over groups of paths with flexible organization,
967
+ producing either tuples (if `gid` is True): (group_index, group_data)
968
+ or just `group_data` items (DataFrame or CollectionTable)
969
+
970
+ - `group_index` - tuple of (or single) values of categories used for grouping
971
+ - `group_data` - grouped data elements in form of DataFrame
972
+
973
+ Specific structure of the group is controlled by arguments:
974
+
975
+ * by which categories the items are grouped as iteration entities:
976
+ * the content and structure of the `group_data` collection (DataFrame)
977
+
978
+ Multi-level indexing is supported for one axis of the frame, by default for the rows.
979
+ That allows to combine hierarchical access to the labels values and iteration.
980
+
981
+ Example:
982
+
983
+ >>> ds.categories == ['scene', 'alg', 'kind', 'side', 'time', 'path', 'transforms']
984
+ >>> (scene, alg), group = next(
985
+ ... ds.iter(['scene', 'alg'], index=['kind', 'side'], data='path'))
986
+ ... # Notice drop of all the categories not explicitly requested as index or data,
987
+ ... # like 'ext', 'transforms', but 'time' remains to ensure unique indexing
988
+ ... scene, alg == 'Piano', 'NU4'
989
+ ... group.index == ['kind', 'side', 'time']
990
+ ... group.columns == ['path']
991
+
992
+ :param shuffle:
993
+ :param group: one or list of categories to group by.
994
+ :param index: list of categories to be used as multi-index levels
995
+ of the resulting groups (also defines their order)
996
+ if 'db_index' use the index of the db
997
+ if ``None`` - keep original index, or
998
+ remove from it ''group'' levels if ``gid`` is True
999
+ :param data: keep those categories in the data (as colums)
1000
+ if `None`: produce `DataFrame` with all data categories as columns
1001
+ :param gid: if True return namedtuple `GID(gid, grp)` otherwise only `grp`
1002
+ :param out: form of the output (None|"frame"|"series"|"pivot")
1003
+ if "frame": return DataFrame with columns as data categories
1004
+ if "series": return `Series` - only if single data column is defined!
1005
+ if "pivot": return DatFrame in pivoted form - to have data-columns as rows,
1006
+ and the `index` argument as columns
1007
+ - column-based multi-level indexing allows hierarchical access:
1008
+ `data.level1.level2 == value`
1009
+ - row-based may be more convinient to `data.itertuples()`:
1010
+ `for (level1, level2), values in data.itertuples():`
1011
+ if None (default): operates as "frame" or "series" if single data column
1012
+ :param trans: a tuple (inp, out), or just out, equivalent to (out, out) -
1013
+ columns names to apply transforms and alignments (in-place):
1014
+ `inp -> apply_transforms -> out -> align_transformed -> out`
1015
+ If more flexibility is required, disable automatic transfomations
1016
+ trans = False or None, and apply explicitely calling
1017
+ `from ds.transform: apply_transforms, align_transformed`
1018
+ :param progress: Show progress bar if `True` or `dict` with args for `tqdm`,
1019
+ `None` - decide automatically
1020
+ :param shuffle: shuffle order of groups
1021
+ :param islice: arguments for ``itertool.islice`` (start, [stop, [step]])
1022
+ :return: iterator over tuples: ((grouped by categories), group)
1023
+ or, if gid is False - over group CollectionTable ( or DataSeries)
1024
+ """
1025
+ import pandas as pd
1026
+ if not (group := group or self.bundle):
1027
+ raise ValueError("Neither group nor bundle is defined")
1028
+
1029
+ if trans: # various options to define dict(inp=..., out=...)
1030
+ if trans is True:
1031
+ trans = [Col.path, Col.data]
1032
+ elif isinstance(trans, str):
1033
+ trans = [trans] * 2
1034
+ if not isinstance(trans, dict):
1035
+ trans = dict(zip(('inp', 'out'), trans))
1036
+ assert len(trans) == 2 and {'inp', 'out'}.issubset(trans)
1037
+
1038
+ data = (trans['out'] if trans else list(self.db.columns)
1039
+ ) if data is None else as_list(data)
1040
+ group = as_list(group)
1041
+ if not index:
1042
+ index = self.categories.difference([*self.db.columns, *(group if gid else [])])
1043
+ elif index == 'db_index': # ToDo: rename to 'keep'
1044
+ index = self.db.index.names
1045
+
1046
+ def prep_group(item: Tuple[Tuple, pd.DataFrame]) -> Tuple[Tuple, pd.DataFrame]:
1047
+ idx, grp = item
1048
+ if trans:
1049
+ grp = tr.apply_column_transform(grp, **trans)
1050
+ grp = grp.sort_index()
1051
+ if isinstance(grp, pd.DataFrame):
1052
+ grp = grp[data]
1053
+ if out == "pivot":
1054
+ grp = grp.T.squeeze()
1055
+
1056
+ return self.Group(group, idx, grp) if gid else grp
1057
+
1058
+ return DataCollection.Iter(map(prep_group, pdt.group_iter(
1059
+ self.db, group=group, index=index, shuffle=shuffle, islice=islice,
1060
+ out=out, progress=progress
1061
+ )), tuples=gid)
1062
+
1063
+ @property
1064
+ def empty(self):
1065
+ return self.db.empty
1066
+
1067
+ def hash_str(self, fmt='{name}_{shape}_{hash}', *, columns=None, index=True, n=6, **kws):
1068
+ """Calculate string uniquely representing this data collection.
1069
+
1070
+ Content of the string can be controlled by including tags: ``{name}, {shape}, {hash}``
1071
+ representing correspondingly: collection name, its db shape, and db content hash.
1072
+
1073
+ Length of the hash is controlled by argument ``n``, when 0 means all of it.
1074
+
1075
+ :param fmt: format string with tags to substitute
1076
+ :param n: length of hash str from internal db
1077
+ :param columns: list of columns to include in hash calculation
1078
+ :param index: use or not index for hash calculation
1079
+ :param kws: additional tags with values to use in format (external)
1080
+ """
1081
+ df = self.db[columns] if columns else self.db
1082
+ hash_val = '{hash}' in fmt and df.hash_str(n, index=index) or None
1083
+ return fmt.format(name=self.name, hash=hash_val,
1084
+ shape='x'.join(map(str, self.db.shape)), **kws)
1085
+
1086
+ @staticmethod
1087
+ def collect(labels_iter: Iterable[dict], progress=True) -> pd.DataFrame:
1088
+ """ Gathers files categories into a database supporting :met:`__len__`
1089
+ and queries by categories' values.
1090
+
1091
+ May be overridden to support different database or caching.
1092
+ Then method :met:`query` must be overridden too.
1093
+
1094
+ :param labels_iter: iterable over dicts of labels
1095
+ :param progress: control the display of progress
1096
+ :returns: database object
1097
+ """
1098
+ import pandas as pd
1099
+ if progress:
1100
+ from tqdm import tqdm
1101
+ from os.path import split
1102
+ loc = split(getattr(labels_iter, 'root', 'file structure'))[-1]
1103
+ labels_iter = tqdm(labels_iter, f"Parsing {loc}", unit=' files')
1104
+
1105
+ return pd.DataFrame(list(labels_iter))
1106
+
1107
+ @staticmethod
1108
+ def query_description(query: str | dict) -> str:
1109
+ """Try to identify selection description from the query"""
1110
+ if not query:
1111
+ return ''
1112
+ return _dict_to_query(query) if isinstance(query, dict) else query
1113
+
1114
+
1115
+ class SinkRepo:
1116
+ """
1117
+ Repository supporting addition of new items, but not reading.
1118
+ Data is saved according to the given path scheme and with
1119
+ specific formats of files depending on the labels.
1120
+ """
1121
+
1122
+ SAVE_KWS_TAG = 'imsave_args'
1123
+ _labels_tag = 'DATA_LABELS'
1124
+
1125
+ def __init__(self, *, root: str | Path, search: 'GuideScan',
1126
+ labels: dict = None, mappings: dict = None,
1127
+ scheme_name: str = None, scheme_description: str = None,
1128
+ data='data', select: str | dict = None,
1129
+ extra_labels: dict = None, create_dir=True):
1130
+ """
1131
+ Construct write only repository from resolved scheme parameters.
1132
+
1133
+ For name-based construction from ``DatasetRM`` or ``SchemeRM``, use
1134
+ the factory in ``dataman`` (e.g. ``dataman.create_collection``).
1135
+
1136
+ :param root: folder to save under
1137
+ :param search: GuideScan defining the path pattern
1138
+ :param labels: scheme label definitions
1139
+ :param mappings: scheme label mappings
1140
+ :param scheme_name: name for display purposes
1141
+ :param scheme_description: description for display purposes
1142
+ :param data: key of the label with data (image) (default: 'data')
1143
+ :param select: conditions on labels as dict or eval string
1144
+ :param extra_labels: labels to add to every saved item
1145
+ :param create_dir: if False and don't exist raise ``NotADirectoryError``
1146
+ """
1147
+ from .caster import Labeler
1148
+ from .scan import GuideScan
1149
+ self.labeler = Labeler(labels=labels or {}, mappings=mappings or {},
1150
+ search=search, reverse=True)
1151
+ self.scheme_name = scheme_name
1152
+ self.scheme_description = scheme_description
1153
+ self.root = Path(root)
1154
+ if not self.root.is_dir():
1155
+ msg = f'SinkRepo folder does not exist: {str(self.root)}'
1156
+ if not create_dir:
1157
+ raise NotADirectoryError(msg)
1158
+ _log.warning(msg)
1159
+ self._data_key = data
1160
+ self._filter = _dict_to_query(select)
1161
+ self.labels = extra_labels or {}
1162
+
1163
+ def __str__(self):
1164
+ return f"<{type(self).__name__}>({self.root}){self.labeler._pather.form.str}"
1165
+
1166
+ def __repr__(self):
1167
+ from iad.core.strings import indent_lines as ind
1168
+ seq = lambda _: ', '.join(_)
1169
+ return f"<{type(self).__name__}>({self.root}):\n" + \
1170
+ ind(self.labeler._pather.form.str,
1171
+ ind(f"- categories: {seq(self.labeler.categories)}",
1172
+ f"- anonymous: {seq(self.labeler.anonymous)}"),
1173
+ f"scheme: {self.scheme_name or ''}",
1174
+ self.scheme_description or '')
1175
+
1176
+ def _prepare_path(self, labels: dict, no_tag=None, exist_ok=None) -> Path:
1177
+ path = self.path(no_tag=no_tag, **labels)
1178
+ if path.exists():
1179
+ if exist_ok is False:
1180
+ raise FileExistsError(path)
1181
+ if exist_ok is None:
1182
+ _log.warning(f'Overwriting {path}')
1183
+ else:
1184
+ path.parent.mkdir(mode=0o777, parents=True, exist_ok=True)
1185
+ return path
1186
+
1187
+ def path(self, no_tag: dict | bool | None = None, **labels):
1188
+ """
1189
+ Construct path according to the repo scheme based on the given labels.
1190
+
1191
+ Raise ``KeyError`` if required categories are missing in labels.
1192
+ Except of 'date' which is then generated automatically.
1193
+
1194
+ `no_tag` controls content of possible unnamed TAG in the pattern.
1195
+ - if a `dict` - used if unnamed TAG is defined or fails.
1196
+ - if `True` - uses for that all the unknown labels (or similarly fails)
1197
+ - if `False` or `None` - ignores unknown tags in the `labels`
1198
+
1199
+ :param no_tag: labels for unnamed tag or permission to take unknown from the `labels`
1200
+ :param labels: {cat: val}
1201
+ :return: path to the file
1202
+ """
1203
+ labels |= self.labels
1204
+
1205
+ if 'date' in self.labeler.categories and 'date' not in labels:
1206
+ from datetime import datetime
1207
+ labels['date'] = datetime.now().strftime('%y%m%d-%H%M%S')
1208
+
1209
+ # remove label
1210
+ if 'data' in labels:
1211
+ labels.pop('data')
1212
+
1213
+ return self.labeler.path(self.root, no_tag=no_tag, **labels)
1214
+
1215
+ def save_tables(self, labels: dict = None, exist_ok=True, **tables: pd.DataFrame | pd.Series):
1216
+ """
1217
+ Save pandas tables: DataFrames or Series.
1218
+ Format (hdf or xls) is defined by extension in scheme's path
1219
+
1220
+ Tables are found by type among the labels and each is stored in
1221
+ separate sheet / node in the data, named by its key.
1222
+ Other labels are also saved as auto-created 'labels' Series
1223
+
1224
+ All the labels are updating those provided in constructor.
1225
+
1226
+ :param labels: dict with labels defining the data
1227
+ :param exist_ok: if False and file exists raises `FileExistsError`
1228
+ if True - ignores existed
1229
+ if None: warning
1230
+ :param kws: additional labels
1231
+ :return:
1232
+ """
1233
+ import pandas as pd
1234
+ labels = self.labels | (labels or {}) # combine labels into single dict
1235
+ path = self._prepare_path(labels, exist_ok=exist_ok)
1236
+
1237
+ is_sub = lambda x, cls: type(x) is not cls and issubclass(type(x), cls)
1238
+ to_base = lambda x: (pd.DataFrame(x) if is_sub(x, pd.DataFrame) else
1239
+ pd.Series(x) if is_sub(df, pd.Series) else x)
1240
+
1241
+ with Timer(f"Table saved in {{:.3}}s to {path}", _log.info):
1242
+ ext = path.suffix.lower()
1243
+ if ext in ('.hdf', '.hdf5'):
1244
+ if any('.' in name for name in tables):
1245
+ import warnings
1246
+ from tables import NaturalNameWarning
1247
+ warnings.filterwarnings('ignore', category=NaturalNameWarning)
1248
+
1249
+ with pd.HDFStore(path, mode='a') as store:
1250
+ if labels:
1251
+ store.put(self._labels_tag, pd.Series(labels))
1252
+
1253
+ for key, df in tables.items():
1254
+ store.put(key, to_base(df)) # pytabels failing on pandas subclasses
1255
+
1256
+ elif ext in '.xls': # TODO: add index description meta-data
1257
+ with pd.ExcelWriter(path) as store:
1258
+ if labels:
1259
+ pd.Series(labels).to_excel(self._labels_tag, sheet_name=self._labels_tag)
1260
+
1261
+ for key, df in tables.items():
1262
+ df.to_excel(store, sheet_name=key, merge_cells=True)
1263
+ else:
1264
+ raise NotImplementedError(f'Not supported saving DataFrame in {path.suffix} format')
1265
+
1266
+ self.read(labels=labels)
1267
+
1268
+ @staticmethod
1269
+ def _default_compression(a: np.ndarray, path: str):
1270
+ """Deduce default compression kwargs from path (ext) and data (dtype)"""
1271
+ if path.rsplit('.', 1)[-1] in ('tif', 'tiff'):
1272
+ if a.dtype.kind == 'f':
1273
+ return {"compression": 'zstd', 'predictor': 3}
1274
+ else:
1275
+ return {"compression": "jpeg2000"}
1276
+ return {}
1277
+
1278
+ def save(self, items: pd.DataFrame | Iterable[dict] | pd.Series | dict, *,
1279
+ data: str | np.ndarray = None, compress: bool | dict | None = False,
1280
+ labels: dict = None, exist_ok=None, no_tag: dict | bool = None) -> int:
1281
+ """
1282
+ Saves data item(s) to the repository according to its labels structure.
1283
+
1284
+ Every data item contains labels for identification and establishing
1285
+ its naming, location and formatting in the repository.
1286
+
1287
+ A single item may be passed in form of dict-like object with
1288
+ optional data field (ndarray) and named 'data' or as indicated by
1289
+ `data` argument if provided.
1290
+ Alternatively data (ndarray) may be passed in the `data` argument.
1291
+
1292
+ Multiple items passed as:
1293
+ - DataFrame - row per item, considering columns and named levels
1294
+ in multi-level index as labels, or
1295
+ - Iterable of dict-like objects
1296
+ In this case `data` argument may be used to specify data field name
1297
+
1298
+ `no_tag` controls content of possible unnamed TAG in the pattern.
1299
+ - if a `dict` - used if unnamed TAG is defined or fails.
1300
+ - if `True` - uses for that all the unknown labels (or similarly fails)
1301
+ - if `False` or `None` - ignores unknown tags in the `labels`
1302
+
1303
+ **Compression**
1304
+
1305
+ Optional (loseless) of data can be controlled by setting `compress=True`
1306
+ or providing dict with arguments for `skimage.io.imsave` or `tifffile.imwrite`,
1307
+ depending on the file extension, `False` or `None` diables altogether.
1308
+
1309
+ If `True`, compression parameters are first looked for in provided
1310
+ `labels[SinkRepo.SAVE_KWS_TAG]`, if not found, then
1311
+ `self._default_compression(data, path)` is called to determine the optimal.
1312
+
1313
+ Examples:
1314
+ ---------
1315
+ >>> sink.save(df, data='image') # data in 'image' column
1316
+ >>> sink.save(df.iloc[10]) # Series from a single row
1317
+ >>> sink.save([dict(a=10, b=20, data=rand(10,20)),
1318
+ ... dict(a=10, b=22, data=rand(30,20))])
1319
+ >>> sink.save(dict(a=10, b=20), rand(10,20))
1320
+
1321
+ :param items: one or multiple set of data labels.
1322
+ - dict, Series
1323
+ - DataFrame, container or iterator overt dicts
1324
+ :param data: alternative name for data key in `items`, or
1325
+ data array itself, then items contains only labels
1326
+ :param compress: dict with compression arguments for
1327
+ OR `True` for auto, `Fasle` - disable
1328
+ :param labels: optionally common labels for all the items.
1329
+ (could be overidden by the items labels)
1330
+ :param exist_ok: if False and file exists raises `FileExistsError`
1331
+ if True - ignores existed
1332
+ if None: warning
1333
+ :param no_tag: labels for unnamed tag or permission to take unknown from the `labels`
1334
+ :return: number of data items saved
1335
+ """
1336
+ from iad.io.imwrite import imsave
1337
+ from iad.io.imread import imread
1338
+ import numpy as np
1339
+ import pandas as pd
1340
+ import os.path
1341
+ from tqdm import tqdm
1342
+
1343
+ is_str = lambda _: isinstance(_, str)
1344
+ count = 0
1345
+
1346
+ compress = compress or {}
1347
+ if not (compress is True or isinstance(compress, dict)):
1348
+ raise TypeError(f"Invalid {compress=}")
1349
+
1350
+ def save_item(lbs: dict, a: np.ndarray):
1351
+ nonlocal count
1352
+ path = self._prepare_path(labels | lbs, no_tag=no_tag, exist_ok=exist_ok)
1353
+ path = str(path)
1354
+ if os.path.isfile(path):
1355
+ if exist_ok:
1356
+ # skipping writing existing files
1357
+ return count
1358
+ if not isinstance(a, np.ndarray):
1359
+ if os.path.isfile(a):
1360
+ a = imread(a)
1361
+ else:
1362
+ raise TypeError(f"Received {type(a)} instead of expected numpy array! or path to file!")
1363
+
1364
+ if (kws := compress) is True: # if auto compression is requested
1365
+ if (kws := lbs.get(self.SAVE_KWS_TAG, None)) is None: # and not defined in labels
1366
+ kws = self._default_compression(a, path) # try to determine default
1367
+
1368
+ imsave(path, a, **(kws or {}))
1369
+ _log.debug(f"Saved {kws or ''} in {self}: {path}")
1370
+ count += 1
1371
+ return count
1372
+
1373
+ # -----------------------------------
1374
+ labels = labels or {}
1375
+ if isinstance(data, np.ndarray):
1376
+ return save_item(items, data)
1377
+
1378
+ data = data or self._data_key
1379
+
1380
+ if isinstance(items, dict):
1381
+ return save_item(items, items[data])
1382
+ if isinstance(items, pd.Series) and all(map(is_str, items)):
1383
+ items = items.to_dict()
1384
+ return save_item(items, items[data])
1385
+
1386
+ if isinstance(items, (pd.Series, pd.DataFrame)):
1387
+ named_levels = list(filter(bool, items.index.names))
1388
+ # leave only required items
1389
+ for _, row in DataCollection._select(
1390
+ items.reset_index(named_levels), query=self._filter
1391
+ ).iterrows():
1392
+ row = row.to_dict()
1393
+ save_item(row, row[data])
1394
+ elif isinstance(items, Iterable):
1395
+ for item in tqdm(items):
1396
+ if isinstance(item, dict):
1397
+ save_item(item, item[data])
1398
+ else:
1399
+ if count:
1400
+ raise KeyError("Attempt to save multiple data items under same labels")
1401
+ save_item({}, item)
1402
+ else:
1403
+ raise TypeError
1404
+
1405
+ return count
1406
+
1407
+ def read(self, keys: str | Sequence[str] = None, *,
1408
+ labels: dict = None, path: str = None, out_labels=False):
1409
+ """Read data from repository given labels
1410
+ (preferable) or path (hack)
1411
+
1412
+ :param keys: name(s) of the fields to read
1413
+ :param labels: identifying item in the repo
1414
+ :param path: instead of labels path of the file
1415
+ :param out_labels: if True return additionally this item's labels dict
1416
+ :return DataFrame if ``key`` is a str, otherwise namedtuple
1417
+ """
1418
+ import pandas as pd
1419
+ assert path is None or labels is None
1420
+ path = path or self.path(**labels)
1421
+ if path.suffix.lower() in {'.hdf', '.hdf5'}:
1422
+
1423
+ outputs = lambda d: (
1424
+ d, hdf.get(self._labels_tag)
1425
+ if self._labels_tag in hdf.keys() else None
1426
+ ) if out_labels else d
1427
+
1428
+ with pd.HDFStore(path, mode='r') as hdf:
1429
+ if isinstance(keys, str): # return specific key
1430
+ return outputs(hdf.get(keys))
1431
+
1432
+ res = {k: hdf.get('/' + k) for k in
1433
+ (keys or (k[1:] for k in hdf.keys()))}
1434
+ res.pop(self._labels_tag, None)
1435
+
1436
+ return outputs(res)
1437
+
1438
+ raise NotImplementedError
1439
+
1440
+ def __call__(self, *args: dict, exist_ok=None, **kwargs):
1441
+ """Function form interface for saving data.
1442
+ Calls to `save` or `save_table` method depending on the sink type
1443
+
1444
+ :param *args: dict-like data items
1445
+ :param exist_ok: if True - ignores existed
1446
+ if False - raise error
1447
+ if None: warning
1448
+ """
1449
+ if self.labeler._pather.ext.lower() in {'xls', 'hdf', 'hdf5'}:
1450
+ return self.save_tables(*args, exist_ok=exist_ok, **kwargs)
1451
+ else:
1452
+ return self.save(*args, exist_ok=exist_ok, **kwargs)
1453
+
1454
+
1455
+ def _dict_to_query(dq: str | dict):
1456
+ """Convert dict to query string with and between items"""
1457
+ if not dq or isinstance(dq, str):
1458
+ return dq
1459
+ if not isinstance(dq, dict):
1460
+ raise TypeError("Invalid query type")
1461
+
1462
+ def to_str(it):
1463
+ k, v = it
1464
+ return f"{k}=='{v}'" if isinstance(v, str) else f"{v}"
1465
+
1466
+ return ' and '.join(map(to_str, dq.items()))