ialdev-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iad/core/__init__.py +9 -0
- iad/core/array.py +1961 -0
- iad/core/binary.py +377 -0
- iad/core/cache.py +903 -0
- iad/core/codetools.py +203 -0
- iad/core/datatools.py +671 -0
- iad/core/docs/locators.ipynb +754 -0
- iad/core/dotstyle.py +99 -0
- iad/core/env.py +271 -0
- iad/core/events.py +650 -0
- iad/core/filesproc.py +1046 -0
- iad/core/fnctools.py +390 -0
- iad/core/label.py +240 -0
- iad/core/logs.py +182 -0
- iad/core/nptools.py +449 -0
- iad/core/one_dark.puml +881 -0
- iad/core/param/__init__.py +17 -0
- iad/core/param/confargparse.py +55 -0
- iad/core/param/paramaze.py +339 -0
- iad/core/param/tbox.py +277 -0
- iad/core/paths.py +563 -0
- iad/core/pdtools.py +2570 -0
- iad/core/pydantools/__init__.py +5 -0
- iad/core/pydantools/fixed_pydantic_yaml/__init__.py +32 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/__init__.py +0 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/hacks.py +76 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/old_enums.py +37 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/representers.py +92 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/types.py +122 -0
- iad/core/pydantools/fixed_pydantic_yaml/compat/yaml_lib.py +104 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/__init__.py +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/semver.py +152 -0
- iad/core/pydantools/fixed_pydantic_yaml/ext/versioned_model.py +113 -0
- iad/core/pydantools/fixed_pydantic_yaml/main.py +30 -0
- iad/core/pydantools/fixed_pydantic_yaml/mixin.py +281 -0
- iad/core/pydantools/fixed_pydantic_yaml/model.py +20 -0
- iad/core/pydantools/fixed_pydantic_yaml/py.typed +1 -0
- iad/core/pydantools/fixed_pydantic_yaml/version.py +1 -0
- iad/core/pydantools/models.py +560 -0
- iad/core/regexp.py +348 -0
- iad/core/short.py +308 -0
- iad/core/strings.py +635 -0
- iad/core/unc_panda.py +270 -0
- iad/core/units.py +58 -0
- iad/core/wrap.py +420 -0
- ialdev_core-0.1.0.dist-info/METADATA +73 -0
- ialdev_core-0.1.0.dist-info/RECORD +48 -0
- ialdev_core-0.1.0.dist-info/WHEEL +4 -0
iad/core/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)}"
|