ialdev-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. iad/core/__init__.py +9 -0
  2. iad/core/array.py +1961 -0
  3. iad/core/binary.py +377 -0
  4. iad/core/cache.py +903 -0
  5. iad/core/codetools.py +203 -0
  6. iad/core/datatools.py +671 -0
  7. iad/core/docs/locators.ipynb +754 -0
  8. iad/core/dotstyle.py +99 -0
  9. iad/core/env.py +271 -0
  10. iad/core/events.py +650 -0
  11. iad/core/filesproc.py +1046 -0
  12. iad/core/fnctools.py +390 -0
  13. iad/core/label.py +240 -0
  14. iad/core/logs.py +182 -0
  15. iad/core/nptools.py +449 -0
  16. iad/core/one_dark.puml +881 -0
  17. iad/core/param/__init__.py +17 -0
  18. iad/core/param/confargparse.py +55 -0
  19. iad/core/param/paramaze.py +339 -0
  20. iad/core/param/tbox.py +277 -0
  21. iad/core/paths.py +563 -0
  22. iad/core/pdtools.py +2570 -0
  23. iad/core/pydantools/__init__.py +5 -0
  24. iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
  25. iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
  26. iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
  27. iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
  28. iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
  29. iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
  30. iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
  31. iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
  32. iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
  33. iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
  34. iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
  35. iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
  36. iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
  37. iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
  38. iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
  39. iad/core/pydantools/models.py +560 -0
  40. iad/core/regexp.py +348 -0
  41. iad/core/short.py +308 -0
  42. iad/core/strings.py +635 -0
  43. iad/core/unc_panda.py +270 -0
  44. iad/core/units.py +58 -0
  45. iad/core/wrap.py +420 -0
  46. ialdev_core-0.1.0.dist-info/METADATA +73 -0
  47. ialdev_core-0.1.0.dist-info/RECORD +48 -0
  48. ialdev_core-0.1.0.dist-info/WHEEL +4 -0
iad/core/datatools.py ADDED
@@ -0,0 +1,671 @@
1
+ """
2
+ Data structures manipulation tools
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Dict, Tuple, Collection, Union, Iterable, \
7
+ Mapping, Any, Generator, Sequence, Callable, Literal, List, Type, TYPE_CHECKING
8
+
9
+ from .codetools import NamedObj
10
+
11
+ if TYPE_CHECKING:
12
+ import re
13
+
14
+ DICTS = Union[Collection[dict], Dict[str, dict]]
15
+
16
+
17
+ def transpose(seq: Sequence[Any | Sequence[Any]], cols: int = None) -> list:
18
+ """Rearrange sequence as in transposition of a matrix with cols
19
+ composed of its elements, and return list of relocated elements.
20
+
21
+ Supports 1d AND 2d cases:
22
+ - 1D rearranges elements as if there were 2D, ``cols`` must divide ``len(seq)``
23
+ ::
24
+ seq[N] -> m[N/cols, cols].T -> seq[N]
25
+ - 2D must be a sequence of sequences of equal length ``cols``
26
+
27
+ Examples:
28
+
29
+ >>> transpose([1,2,3,4,5,6], 3)
30
+ [1, 4, 2, 5, 3, 6]
31
+
32
+ >>> transpose([[1,2], [3,4], [5,6]])
33
+ [[1, 3, 5], [2, 4, 6]]
34
+ """
35
+
36
+ if cols is None:
37
+ cols = len(seq[0])
38
+ return [list(r[i] for r in seq) for i in range(cols)]
39
+ else:
40
+ return [seq[i] for ofs in range(cols) for i in range(ofs, len(seq), cols)]
41
+
42
+
43
+ UndefTypes = Union['UndefCond', Collection, Callable[[Any], bool], Literal[None]]
44
+ UNDEF = NamedObj('UNDEF')
45
+
46
+
47
+ class UndefCond:
48
+ """
49
+ Contains condition of an item to be considered undefined.
50
+ Instances are callable and implement this check.
51
+ """
52
+
53
+ def __init__(self, undef: UndefTypes = None, empty_dict=False):
54
+ """
55
+ May be initialized also by another UndefCond instance or
56
+ a callable returning True if its argument should be considered undefined.
57
+
58
+ In those cases ``empty_dict`` argument is ignored.
59
+
60
+ If any element of provided collection IS a tested object, it is declared undefined.
61
+
62
+ In addition, if empty_dict is True, then any empty object of dict subclass is undefined.
63
+
64
+ An object which IS `UndefCond.UNDEF` is always considered undefined.
65
+ """
66
+ if isinstance(undef, UndefCond):
67
+ self.undef = undef.undef
68
+ self.empty_dict = undef.empty_dict
69
+ elif undef is None:
70
+ self.undef = ()
71
+ self.empty_dict = empty_dict
72
+ elif hasattr(undef, '__contains__'):
73
+ self.undef = undef
74
+ self.empty_dict = empty_dict
75
+ elif isinstance(undef, Callable):
76
+ self.undef = None
77
+ self.empty_dict = None
78
+ setattr(self, '__call__', undef)
79
+ else:
80
+ raise TypeError(f"Unsupported undef argument type f{type(undef)}")
81
+
82
+ def __repr__(self):
83
+ if self.empty_dict is None: # constructed by callable
84
+ rep = f"func<{self.__call__.__qualname__}>"
85
+ else:
86
+ rep = [f"{self.undef}"] if self.undef else []
87
+ if self.empty_dict: rep.append("{}")
88
+ rep = ', '.join(rep)
89
+ return f"UndefCond({rep})"
90
+
91
+ def __call__(self, x):
92
+ return (x is UNDEF or x in self.undef or
93
+ self.empty_dict and isinstance(x, dict) and not x)
94
+
95
+
96
+ def complete_missing(tree: dict, other: dict, *,
97
+ undef: Collection | Callable[[Any], bool] | UndefCond = None):
98
+ """
99
+ Complete missing elements in dict hierarchy from another tree,
100
+ leaving values of existing leaves unchanged.
101
+
102
+ :param tree: tree to complete with extra elements found in the other
103
+ :param other: to take new elements from
104
+ :param undef: definition of what constitutes an undefined object:
105
+ None, a collection of such objects, or callable to performa
106
+
107
+
108
+ :return: tree of all the new nodes added to the tree
109
+ """
110
+ undef = undef if isinstance(undef, UndefCond) else UndefCond(undef)
111
+
112
+ new = type(tree)() # new nodes added
113
+ for k, ov in other.items():
114
+ tv = dict.get(tree, k, UNDEF)
115
+ # all the cases when tv is considered UNDEF
116
+ if undef(tv):
117
+ tree[k] = ov
118
+ elif isinstance(tv, dict) and isinstance(ov, dict):
119
+ ov = complete_missing(tv, ov, undef=undef)
120
+ else:
121
+ continue # tv is defined and not a recursive 2 dicts case
122
+ new[k] = ov
123
+ return new
124
+
125
+
126
+ def common_dict(dicts: DICTS, unique=False
127
+ ) -> Union[dict, Tuple[dict, DICTS]]:
128
+ """
129
+ Given a collection of dicts find a common sub-dictionary.
130
+ Optionally return also list of remaining unique sub-dictionaries.
131
+
132
+ :param dicts: sequence or dictionary of dictionaries to select from
133
+ :param unique: if True return also the unique sub-dictionaries
134
+ as collection or dictionary, depending on the input
135
+ :return: common | common, unique
136
+ """
137
+ from toolz import itemfilter, reduce, comp
138
+
139
+ keys = None
140
+ if isinstance(dicts, dict):
141
+ keys, dicts = dicts.keys(), [*dicts.values()]
142
+
143
+ for d in dicts: # convert all lists to tuple making them hashable
144
+ transform_node(d, lambda _: isinstance(_, list), 'value', lambda _: tuple(_), 'value')
145
+
146
+ items_sets = map(lambda d: set(d.items()), dicts)
147
+ common = reduce(set.intersection, items_sets)
148
+ if unique:
149
+ def remove_common(dct):
150
+ return itemfilter(lambda it: it not in common, dct)
151
+
152
+ uniques = map(comp(dict, remove_common), dicts)
153
+ return dict(common), list(uniques) if keys is None else dict(zip(keys, uniques))
154
+ return dict(common)
155
+
156
+
157
+ def zip_dict(*dicts, fillvalue=None, keys=None, skip=False, strict=False):
158
+ """
159
+ zip dictionaries with same keys into dict of tuples of the corresponding values.
160
+ :param dicts: dicts to zip
161
+ :param fillvalue: fill with this value if key is missing in some dictionary (unless strict is True!)
162
+ :param keys: None | <iterable> - all the keys in the dicts OR use keys from specifically provided iterable
163
+ :param skip: True|False
164
+ :param strict: [False]|True - raise KeyError for any missing keys (otherwise use fillvalue)
165
+ :return: Dict[Key: Tuple]
166
+ """
167
+ if keys is None:
168
+ keys = {k for d in dicts for k in d} # all the keys found in all the dicts
169
+
170
+ def get(d, k): # return key's value or fillvalue or raise Exception - depending on the settings
171
+ return d[k] if strict else d.get(k, fillvalue)
172
+
173
+ def available(k): # check if the key is available in all the dicts
174
+ return all((k in d) for d in dicts)
175
+
176
+ return {k: tuple(get(d, k) for d in dicts) for k in keys if not skip or available(k)}
177
+
178
+
179
+ def split_sync_iter(itr: Iterable[Mapping[int, Any]], splitter=None, n=None) -> Tuple[Generator, ...]:
180
+ """
181
+ Given an iterable over items which are sequences of n elements, return
182
+ n synchronized iterators over elements by their order (like transpose).
183
+
184
+ Implementation note
185
+ -------------------
186
+ Since each iterator can be advanced independently, some of them could
187
+ be consumed faster than the others. The source iterator is therefore
188
+ advancing with the fastest, and internal buffers are used to keep
189
+ unconsumed elements of slow iterators - thus the iterators are synced.
190
+
191
+ :param itr: iterable over sequences of items of same length
192
+ :param splitter: optional function to split the item into elements
193
+ default assumes item is an iterator over elements
194
+ :param n: optional number of output channels,
195
+ otherwise determined automatically by peeking
196
+ :return: tuple with n iterators
197
+ """
198
+ from collections import deque
199
+ from toolz import peek
200
+ split = lambda x: splitter(x) if splitter else x
201
+
202
+ if not n:
203
+ sample, itr = peek(itr)
204
+ n = len(split(sample))
205
+ qs = [deque() for _ in range(n)]
206
+
207
+ def _next(i):
208
+ nonlocal qs
209
+ if not qs[i]:
210
+ items = split(next(itr))
211
+ assert len(items) == n
212
+ for q, item in zip(qs, items):
213
+ q.appendleft(item)
214
+ return qs[i].pop()
215
+
216
+ def iterator(i):
217
+ while True:
218
+ yield _next(i)
219
+
220
+ return tuple(map(iterator, range(len(qs))))
221
+
222
+
223
+ def unzip_dict(d):
224
+ """
225
+ Transpose dict of arrays into array of dict:
226
+
227
+ Example:
228
+ unzip_dict({'x': [1, 2, 3], 'y': [10, 20, 30]}) \
229
+ ==
230
+ [{'x': 1, 'y': 10}, {'x': 2, 'y': 20}, {'x': 3, 'y': 30}]
231
+
232
+ :param d: dictionary of arrays of same length
233
+ :return: array of dicts of the same length
234
+ """
235
+ return [dict(zip(d.keys(), vals)) for vals in zip(*d.values())]
236
+
237
+
238
+ def merge_update(trg: dict, src: dict, *, copy=True,
239
+ conflict: Literal['error', 'replace', 'ignore'] = 'error', verb=False) -> dict:
240
+ """
241
+ Merge a src dict into the target by updating its values.
242
+ ::
243
+ merge_update(trg, src) is trg
244
+
245
+ Make a copy to keep the original intact:
246
+ ::
247
+ new = merge_update(trg.copy(), src)
248
+
249
+ Possible conflicts can be handles as instructed by control argument
250
+ :param trg: dict to merge into
251
+ :param src: dict to merge
252
+ :param conflict: error | replace | ignore
253
+ - error: raise KeyError
254
+ - replace: use from src
255
+ - ignore: - leave the old
256
+ :param copy: copy nodes instead of referencing,
257
+ also may be a copy function
258
+ :param verb: be verbose - warn if replace or ignore happens
259
+
260
+ :return: updated version of the input dict.
261
+ """
262
+ from warnings import warn
263
+ if not copy:
264
+ copy = lambda x: x
265
+ elif copy is True:
266
+ from copy import copy
267
+
268
+ for k, v in src.items():
269
+ if k in trg and trg[k] is not None:
270
+ if hasattr(v, 'keys'):
271
+ merge_update(trg[k], v, conflict=conflict, copy=copy)
272
+ else:
273
+ if conflict == 'error':
274
+ raise KeyError(f'Conflict for key {k} ({trg[k]} vs {v})')
275
+ if conflict == 'replace':
276
+ trg[k] = copy(v)
277
+ elif conflict != 'ignore':
278
+ raise ValueError(f'Unsupported conflict argument value: {conflict}')
279
+
280
+ not verb or warn(f'Conflict resolved by "{conflict}" for key {k} ({trg[k]} vs {v})')
281
+ else:
282
+ trg[k] = copy(v)
283
+ return trg # TODO: reconsider copy mechanism
284
+
285
+
286
+ def split_dict(d, cond: Callable):
287
+ """
288
+ Given dict and condition `function(key, val)` return two dicts:
289
+ with items meeting conditions and the rest.
290
+
291
+ :param d:
292
+ :param cond:
293
+ :return: cond_true_dict, cond_false_dict
294
+ """
295
+ pos, neg = {}, {}
296
+ for k, v in d.items():
297
+ (pos if cond(k, v) else neg)[k] = v
298
+ return pos, neg
299
+
300
+
301
+ def rm_keys(d: dict, keys: str | re.Pattern | Iterable[str | re.Pattern],
302
+ *, strict=False):
303
+ """
304
+ remove keys from dict and return it (Make copy before passing if needed!)
305
+ :param d: dict
306
+ :param keys: a scalar key or Iterable over keys
307
+ :param strict: fails if a key required to be removed is not in ``d``
308
+ :return: dict
309
+ """
310
+
311
+ regs, keys_to_rm = [], []
312
+ for k in keys: # separate regular expressions from the keys to remove
313
+ (regs if not isinstance(k, str) or '*' in k or '?' in k else keys_to_rm).append(k)
314
+
315
+ if regs: # extend by the dict keys matching a regular expression
316
+ from .regexp import filter_regex_matches
317
+ keys_to_rm.extend(filter_regex_matches(regs, d))
318
+
319
+ for k in keys_to_rm:
320
+ d.pop(k) if strict else d.pop(k, None)
321
+ return d
322
+
323
+
324
+ def drop_item(seq: Sequence, pos: int):
325
+ """Drop item at given position in the sequence, and return list without it.
326
+
327
+ :param seq: a list to drop item from
328
+ :param pos: index of item to drop (could be negative to count from the end)
329
+
330
+ Example:
331
+ >>> drop_item([1,2,3], 1)
332
+ [1, 3]
333
+ >>> drop_item([1,2,3], -1)
334
+ [1, 2]
335
+ """
336
+ n = len(seq)
337
+ pos = n + pos if pos < 0 else pos
338
+ return [a for i, a in enumerate(seq) if i != pos]
339
+
340
+
341
+ def unique(seq: Iterable, exclude: Iterable = ()) -> Generator:
342
+ """From the given iterable over hashable items
343
+ produce iterator with all the repeated occupancies filtered out.
344
+
345
+ Additional items to exlcude may be optinally provided.
346
+
347
+ Example:
348
+
349
+ >>> list(unique([1, 2, 1, 'cat', 3, 'cat', 'son', 3])) == [1, 2, 'cat', 3, 'son']
350
+ True
351
+ >>> list(unique([1, 0, 1, 3, 0, 1, None], exclude=[None, 0])) == [1, 3]
352
+ True
353
+ """
354
+ seen = set(exclude)
355
+ for x in seq:
356
+ if x in seen: continue
357
+ seen.add(x)
358
+ yield x
359
+
360
+
361
+ def recurring(items: Iterable) -> Generator:
362
+ """
363
+ Produce generator of recurring elements from the given iterable over hashable items
364
+ :param items: hashable
365
+ :return: generator iterating over the recurring items
366
+ """
367
+ found = set()
368
+ for x in items:
369
+ if x in found:
370
+ yield x
371
+ found.add(x)
372
+
373
+
374
+ def map_by_type(values: Collection, types: dict[str, type]):
375
+ """
376
+ Given values and mapping {name: type} create mapping {name: value}
377
+ by establishing value -> type correspondence with isinstance(value, type).
378
+
379
+ Requires uniqueness of types in the mapping.
380
+
381
+ :param values: a collection of values
382
+ :param types: mapping name -> type
383
+
384
+ :return: mapping name -> value for every value from values
385
+
386
+ :raises: TypeError if types are not unique or type of the value not found
387
+ """
388
+ names = {t: n for t, n in types.items()} # invert dictionary
389
+ if len(names) < len(types):
390
+ raise TypeError("Not unique types in the name -> type mapping")
391
+
392
+ assigned = {}
393
+ for v in values: # guess name by its type
394
+ for n, t in types.items(): # by checking all the types
395
+ if isinstance(v, t):
396
+ assigned[n] = v
397
+ break
398
+ else:
399
+ raise TypeError(f"Unexpected type {type(v)} of element {v}")
400
+ return assigned
401
+
402
+
403
+ def select_from(namespace: dict, names: Iterable, strict=True,
404
+ default=UNDEF, factory: Callable[[str], Any] | None = None) -> dict[str, Any]:
405
+ """
406
+ Selects given names from given namespace (dict)
407
+ :param namespace: dictionary like
408
+ :param names: names of the variables to return
409
+ :param strict: if ``True`` names must be in namespace or raise ``KeyError``.
410
+ if ``False``, use only available names from the namespace
411
+ :param default: if `strict` is ``True`` and `default` is defined,
412
+ this value will be returned for not found keys.
413
+ :param factory: *instead* of ``default`` can provide function creating default from name
414
+ :return: dict with selected items
415
+ """
416
+
417
+ if strict:
418
+ if default is UNDEF:
419
+ if factory is None:
420
+ return {k: namespace[k] for k in names}
421
+ else:
422
+ return {k: factory(k) if (v := namespace.get(k, UNDEF)) is UNDEF else v
423
+ for k in names}
424
+ else:
425
+ assert factory is None
426
+ return {k: namespace.get(k, default) for k in names}
427
+ else:
428
+ assert default is UNDEF and factory is None
429
+ return {k: namespace[k] for k in names if k in namespace}
430
+
431
+
432
+ def all_satisfied(conditions: List[Callable[[Any], bool]]) -> Callable[[Any], bool]:
433
+ """From collection of condition testing functions ``cond(x): bool``
434
+ compose a function which returns True if ALL the tests are passed
435
+
436
+ :param conditions: boolean Callables
437
+ :return: boolean function which tests ALL the conditions
438
+ """
439
+
440
+ def func(v):
441
+ return all(map(lambda c: c(v), conditions))
442
+
443
+ return func
444
+
445
+
446
+ def to_dict(d: dict) -> dict:
447
+ """Translate dict-like nodes of hierarchical object into pure dict.
448
+
449
+ :param d: The object, possibly a combination of dict and :class:`Box`.
450
+ :returns: it's modified self
451
+ """
452
+ if hasattr(d, 'items'):
453
+ if hasattr(d, 'to_dict'):
454
+ return d.to_dict()
455
+ else:
456
+ try:
457
+ return dict(d)
458
+ except:
459
+ pass
460
+ for k, v in d.items():
461
+ d[k] = to_dict(v)
462
+ return d
463
+
464
+
465
+ def transform_node(dct: dict,
466
+ condition: Callable[[Any], bool], condition_on: Literal['key', 'value'],
467
+ transformation: Callable[[Any], Any], transformation_on: Literal['key', 'value']):
468
+ """
469
+ Recursively transforming dictionary singular nodes given desired condition.
470
+ Those transformation, as mentioned, applies on non dictionary nodes.
471
+
472
+ For instance:
473
+
474
+ Value Transformation:
475
+ >>> d = {'a': 4}
476
+ >>> inc = lambda _: _.__add__(1)
477
+ >>> dd = transform_node(d, lambda _: isinstance(_, int), condition_on='value',
478
+ ... transformation=inc, transformation_on='value')
479
+ This results in:
480
+ >>> dd = {'a': 5}
481
+
482
+ Key Transformation
483
+
484
+ >>> d = {'a': 4}
485
+ >>> change_name = lambda _: str.__add__(_, 'wesome')
486
+ >>> dd = transform_node(d, lambda _: isinstance(_, str), condition_on='key',
487
+ ... transformation=change_name, transformation_on='key')
488
+ This results in:
489
+ >>> dd = {'awesome': 4}
490
+
491
+ The transformation can be done either on the key or value.
492
+ The function does NOT do anything but calling the condition and transformation passed.
493
+ The responsibility of compatibility between the operation and the data is on the user.
494
+
495
+ :param dct: The given dictionary.
496
+ :param condition: A condition to apply.
497
+ :param condition_on: Whom the condition applied on.
498
+ :param transformation: A transformation to apply.
499
+ :param transformation_on: Whom the tranformation applied on.
500
+ :return: New dictionary after the transformation.
501
+ """
502
+ _dct = dict()
503
+ on_key = transformation_on == 'key'
504
+ for k, v in dct.items():
505
+ if not v:
506
+ dct[k] = None
507
+ else:
508
+ if isinstance(v, dict):
509
+ v = transform_node(v, condition, condition_on, transformation, transformation_on)
510
+ if condition(k if on_key else v):
511
+ _dct[transformation(k) if on_key else k] = v if on_key else transformation(v)
512
+ else:
513
+ _dct[k] = v
514
+
515
+ return _dct
516
+
517
+
518
+ def issubset_report(a: Collection, b: Collection,
519
+ on_empty: Type[Exception] | Callable = None,
520
+ on_diff: Type[Exception] | Callable = None) -> bool:
521
+ """
522
+ Checks whether two input collections contain same set of hashable elements
523
+ and optionally report on certain cases using mechanism provided by
524
+ the corresponding argument:
525
+ - calling it if its values is ``Callable``
526
+ - raising it if it is an ``Exception`` type
527
+
528
+ :param a: First collection.
529
+ :param b: Second collection.
530
+ :param on_empty: report type if both are empty (return ``True`` if not raises)
531
+ :param on_diff: report kind if difference is found (return ``False`` if not raised)
532
+
533
+ :return: ``False`` if collections contain different sets, otherwise ``True``
534
+ """
535
+
536
+ def report(s, rep):
537
+ if callable(rep):
538
+ rep(s)
539
+ elif isinstance(rep, type) and issubclass(rep, Exception):
540
+ raise rep(s)
541
+
542
+ if not (a or b):
543
+ report("Both collections are empty", on_empty)
544
+ elif differences := set(a).symmetric_difference(b):
545
+ report(f"There are {differences=} between {a} and {b}", on_diff)
546
+ return False
547
+ return True
548
+
549
+
550
+ class Filter:
551
+ """
552
+ Class allows to define a composition of conditions to be checked for a
553
+ Mapping objects (labels), expressed in terms of its keys.
554
+
555
+ Its main functionality is represented by __call__ method to evaluate
556
+ given labels object.
557
+
558
+ Conditions must be provided either as a
559
+ - single object, ``Callable[[Dict], bool]`` or expression string
560
+
561
+ >>> f1 = Filter('x > 2 * y')
562
+ >>> def func(d) -> bool:
563
+ ... return d['x'] > 2 * d['y']
564
+ >>> f2 = Filter(func) # same functionality as f1
565
+ >>> labels = dict(x=2, y=3, z=5)
566
+ >>> assert f1(labels) == f2(labels)
567
+
568
+ - dictionary, with string keys, which either
569
+
570
+ - starts with 'condition' with values as in single object case, or
571
+ - a valid label key (found in labels to be filtered).
572
+ In this case values represent expected label values,
573
+
574
+ either a `scalar` (including ``str``), or collection of scallars.
575
+
576
+ Mappings are used only as a set of their keys!
577
+ Example
578
+ -------
579
+ >>> filters = dict(
580
+ ... conditions = 'height * 2 < width - 10', # 1. general condition
581
+ ... name = ['Sam', 'David'], # 2. allowed values
582
+ ... age = int(18).__le__, # 18 <= age # 3. callable
583
+ ... side = 'right' # 4. allowed scalar value
584
+ ... )
585
+ >>> cond = Filter(filters)
586
+ >>> cond_keys = {'height', 'width', 'name', 'age', 'side'}
587
+ >>> labels = dict(height=10, width=4, name='Nick', age=100, side='top')
588
+ >>> assert cond_keys.issubset(labels.keys())
589
+ >>> assert cond(labels) is False
590
+ >>>
591
+ >>> from pandas import DataFrame
592
+ >>> df = DataFrame([
593
+ ... dict(height=10, width=4, name='Nick', age=100, side='top'),
594
+ ... dict(height=10, width=40, name='Sam', age=10, side='right'),
595
+ ... dict(height=10, width=35, name='David', age=20, side='right')
596
+ ... ])
597
+ >>> assert cond_keys.issubset(df.columns)
598
+ >>> assert df[df.apply(cond, axis=1)].index.item() == 2
599
+ >>>
600
+ """
601
+
602
+ def __init__(self, filters: str | dict[str, str | Collection | Any], strict=True):
603
+ """
604
+ Create filter object from collection of filtering conditions to
605
+ be joined by AND operator.
606
+
607
+ Conditions are represented as dict items with keys as:
608
+ 1. labels categories, with values as (key-conditin)
609
+ * allowed value
610
+ * allowed collection of values
611
+ 2. string started with "condition*", than the value is (general condition)
612
+ * string with python expression in term of labels categories evaluated to bool
613
+ * a callable function recieving labels as keyword arguments
614
+
615
+ :param filters: single condition or dict of them
616
+ :param strict: fail if labels during the filtering don't include catagories used in key
617
+ """
618
+ self.strict = strict
619
+
620
+ def valid_condition(cond):
621
+ if isinstance(cond, str):
622
+ from .fnctools import express_to_kw_func
623
+ return express_to_kw_func(cond)
624
+ if isinstance(cond, Callable):
625
+ return cond
626
+ raise TypeError(f"Invalid condition type {type(cond)}, expected Callable or str!")
627
+
628
+ self._filters = filters
629
+ self.general_conditions = {}
630
+ self.key_conditions = {}
631
+ if filters:
632
+ if isinstance(filters, (str, Callable)):
633
+ filters = {'condition': filters}
634
+ assert isinstance(filters, dict)
635
+
636
+ for key, val in filters.items():
637
+ if key.startswith('condition'):
638
+ self.general_conditions[key] = valid_condition(val)
639
+ else:
640
+ if isinstance(val, Callable):
641
+ condition = val
642
+ elif isinstance(val, Collection) and not isinstance(val, str):
643
+ allowed = set(val)
644
+ condition = allowed.__contains__
645
+ else: # a scalar
646
+ condition = lambda x: x == val
647
+ self.key_conditions[key] = condition
648
+
649
+ def __call__(self, labels: dict) -> bool:
650
+ """
651
+ Return True if labels match the filters
652
+ :param labels: a mapping with all filters arguments in its keys
653
+ :return: True if ALL the conditions met
654
+ """
655
+ UND = object()
656
+ return all(cond(labels) for cond in self.general_conditions.values()) and \
657
+ all(cond(v) for k, cond in self.key_conditions.items()
658
+ if (v := labels.get(k, UND)) is not UND or self.strict is False)
659
+
660
+ def detail_conditions(self, labels):
661
+ """Return dictionary of conditions results per condition.
662
+ Mainly for debugging purposes.
663
+ """
664
+ return {**{k: cond(labels) for k, cond in self.general_conditions.items()},
665
+ **{k: cond(labels[k]) for k, cond in self.key_conditions.items()}}
666
+
667
+ def __repr__(self):
668
+ lst = lambda s: '\n' + ', '.join(s) if s else ''
669
+ return f"{self.__class__.__name__} conditions:" \
670
+ f"{lst(self.general_conditions)}" \
671
+ f"{lst(self.key_conditions)}"