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/strings.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""Strings manipulation utils"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Callable, Sequence, Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fuzzy_find(query: str, strings: Sequence[str], *, case=True,
|
|
9
|
+
limit: int | None = 1, score_cutoff=None,
|
|
10
|
+
out: Literal['string', 'index', 'tuple'] = 'all'):
|
|
11
|
+
"""
|
|
12
|
+
Find imperfectly matches to the `query` string in the given sequence of `strings`.
|
|
13
|
+
|
|
14
|
+
If `out_str` is `False` for every found match return tuple of
|
|
15
|
+
- `string` from the `strings` matching the `query`
|
|
16
|
+
- `score` of the match [0, 100]
|
|
17
|
+
- `index` of the found sting in the sequence
|
|
18
|
+
|
|
19
|
+
Return list of found candidates as tuple `(string, score, index)` or
|
|
20
|
+
element specified by the `out`: 'string' or 'index'.
|
|
21
|
+
|
|
22
|
+
:param query: a string to find
|
|
23
|
+
:param strings: sequence of string to scan
|
|
24
|
+
:param case: if `True` - case-sensitive
|
|
25
|
+
:param limit: max number of matched
|
|
26
|
+
:param score_cutoff: drop found with score < score_score_cutoff
|
|
27
|
+
:param out: form of the output items
|
|
28
|
+
|
|
29
|
+
:return: list of found strings or tuple
|
|
30
|
+
"""
|
|
31
|
+
from rapidfuzz.process import extract
|
|
32
|
+
processor = None
|
|
33
|
+
if not case:
|
|
34
|
+
from rapidfuzz.utils import default_process as processor
|
|
35
|
+
|
|
36
|
+
found = extract(query, strings, processor=processor, limit=limit,
|
|
37
|
+
score_cutoff=score_cutoff)
|
|
38
|
+
if out == 'tuple':
|
|
39
|
+
return found
|
|
40
|
+
i = dict(string=0, index=2)[out]
|
|
41
|
+
return [_[i] for _ in found]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def short_form(s: str, head=0, tail=0):
|
|
45
|
+
"""
|
|
46
|
+
Cut head , center or tail of the string if its len exceeds given sizes.
|
|
47
|
+
Replace cut part with '…'.
|
|
48
|
+
|
|
49
|
+
If both tail and head are provided, cut from the center
|
|
50
|
+
|
|
51
|
+
:param s: string to crop
|
|
52
|
+
:param head: len of head part to leave
|
|
53
|
+
:param tail: len of tail part to leave
|
|
54
|
+
|
|
55
|
+
:return: cut str
|
|
56
|
+
"""
|
|
57
|
+
n = len(s)
|
|
58
|
+
if n <= head + tail:
|
|
59
|
+
return s
|
|
60
|
+
if head and tail:
|
|
61
|
+
return f"{s[:head]}…{s[-tail:]}"
|
|
62
|
+
if tail:
|
|
63
|
+
return f'…{s[-tail:]}'
|
|
64
|
+
if head:
|
|
65
|
+
return f'{s[:head]}…'
|
|
66
|
+
return s
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def smart_warp(s, width=80, **splitters):
|
|
70
|
+
"""
|
|
71
|
+
Warp string py applying `splitters` in order to limit each line by requested `width`.
|
|
72
|
+
|
|
73
|
+
Every splitter must be a regular expression to use by `re.split(splitter, line, maxsplit=1)`
|
|
74
|
+
If splitters not provided, defaults are used:
|
|
75
|
+
::
|
|
76
|
+
|
|
77
|
+
splitters = dict(
|
|
78
|
+
bullets = r"(?<=.{2})(?=\s+[-*]\s+)",
|
|
79
|
+
dot_capital = r"(?<=[\.\:])\s+(?=[A-Z])",
|
|
80
|
+
long_dot = r"(?<=.{15}\w{2}\.)\s+(?=\w{2})",
|
|
81
|
+
long_capital = r"(?<=.{25})\s+(?=[A-Z]\w{2})",
|
|
82
|
+
space = r"(?<=.{40})\s+(?=.{20})"
|
|
83
|
+
)
|
|
84
|
+
"""
|
|
85
|
+
transforms = splitters or dict(
|
|
86
|
+
bullets=r"(?<=.{2})(?=\s+[-*]\s+)",
|
|
87
|
+
dot_capital=r"(?<=[\.\:])\s+(?=[A-Z])",
|
|
88
|
+
long_dot=r"(?<=.{15}\w{2}\.)\s+(?=\w{2})",
|
|
89
|
+
long_capital=r"(?<=.{25})\s+(?=[A-Z]\w{2})",
|
|
90
|
+
space=r"(?<=.{40})\s+(?=.{20})"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def apply(lines, tfm):
|
|
94
|
+
# print(f'------------[{tfm}]')
|
|
95
|
+
for line in lines:
|
|
96
|
+
remained = [line]
|
|
97
|
+
while remained: # mau be of size 0 or 1
|
|
98
|
+
if len(line := remained.pop(0)) > width:
|
|
99
|
+
# print(f"[{len(line):4}] {line}")
|
|
100
|
+
line, *remained = re.split(transforms[tfm], line, maxsplit=1)
|
|
101
|
+
# print(' '*6, line)
|
|
102
|
+
yield line
|
|
103
|
+
|
|
104
|
+
# ---------------------------
|
|
105
|
+
lines_iter = [s]
|
|
106
|
+
for t in transforms:
|
|
107
|
+
lines_iter = apply(lines_iter, t)
|
|
108
|
+
|
|
109
|
+
return '\n'.join(lines_iter)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def join_by_groups(ss, sep, mx, mx2=None):
|
|
113
|
+
"""
|
|
114
|
+
From sequence of strings produce a sequence with some of
|
|
115
|
+
subsequent string joined with given separator not to exceed max len.
|
|
116
|
+
:param ss: iterable over strings
|
|
117
|
+
:param sep: separator string to when joing sub-groups
|
|
118
|
+
:param mx: maximal length of a joined string
|
|
119
|
+
:param mx2: if defined try keep lengths between mx and mx2
|
|
120
|
+
:return: iterator over joined strings
|
|
121
|
+
"""
|
|
122
|
+
mx2 = mx2 or mx
|
|
123
|
+
assert mx2 >= mx
|
|
124
|
+
sep_len = len(sep)
|
|
125
|
+
acc = []
|
|
126
|
+
cur = 0
|
|
127
|
+
|
|
128
|
+
next_len = lambda: cur + len(s) + (sep_len if acc else 0)
|
|
129
|
+
|
|
130
|
+
for s in ss:
|
|
131
|
+
nxt = next_len()
|
|
132
|
+
if (cur >= mx or nxt > mx2) and acc:
|
|
133
|
+
yield sep.join(acc)
|
|
134
|
+
acc = []
|
|
135
|
+
cur = 0
|
|
136
|
+
cur = next_len()
|
|
137
|
+
acc.append(s)
|
|
138
|
+
|
|
139
|
+
if acc:
|
|
140
|
+
yield sep.join(acc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def wrap_sep_split(s, mx, *, mx2=None, sep=', ', jsep=None, newline='\n'):
|
|
144
|
+
"""
|
|
145
|
+
Wrap long line by splitting into multiple lines along the separators
|
|
146
|
+
and limited in length by the mx, mx2 (see `join_by_groups`)
|
|
147
|
+
:param s: the string
|
|
148
|
+
:param mx: maximal length
|
|
149
|
+
:param mx2: hard maximal length
|
|
150
|
+
:param sep: separator regexp
|
|
151
|
+
:param jsep: join separator (same as sep if not defined)
|
|
152
|
+
:param newline: replaces the separator in split locations
|
|
153
|
+
:return: wrapped string
|
|
154
|
+
"""
|
|
155
|
+
jsep = jsep or sep
|
|
156
|
+
return newline.join(join_by_groups(filter(len, re.split(sep, s)),
|
|
157
|
+
sep=jsep, mx=mx, mx2=mx2))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def join_wrap(seq, max_line=80, *, head='', sep=',', spc=' ', newline='\n', left='') -> str:
|
|
161
|
+
""" Join sequence of strings by wrapping long lines and adding optional left alignment shift
|
|
162
|
+
:param seq: sequence of string
|
|
163
|
+
:param max_line: maximal length of the line before wrapping
|
|
164
|
+
:param head: head of the string to form - will not be separated by the `sep`
|
|
165
|
+
:param sep: separator between strings - used also on the line wrapping
|
|
166
|
+
:param spc: addition to the separator - inside the line
|
|
167
|
+
:param newline: the newline symbol(s)
|
|
168
|
+
:param left: left prefix string for every line
|
|
169
|
+
return:
|
|
170
|
+
The concatenated string
|
|
171
|
+
"""
|
|
172
|
+
sep_len = len(sep + spc)
|
|
173
|
+
left_len = len(left)
|
|
174
|
+
head_len = len(head)
|
|
175
|
+
res = head
|
|
176
|
+
line_len = head_len
|
|
177
|
+
for s in seq:
|
|
178
|
+
if len(res) == head_len:
|
|
179
|
+
next_len = len(s)
|
|
180
|
+
next_sep = ''
|
|
181
|
+
else:
|
|
182
|
+
next_len = len(s) + sep_len
|
|
183
|
+
next_sep = sep
|
|
184
|
+
|
|
185
|
+
if line_len + next_len > max_line or (next_len + left_len) >= max_line:
|
|
186
|
+
smart_sep = next_sep + newline + left
|
|
187
|
+
line_len = next_len + left_len
|
|
188
|
+
else:
|
|
189
|
+
smart_sep = next_sep + spc
|
|
190
|
+
line_len += next_len
|
|
191
|
+
|
|
192
|
+
res = res + smart_sep + s if res else left + s
|
|
193
|
+
|
|
194
|
+
return res
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def compact_repr(v, max_len=60) -> str:
|
|
198
|
+
"""
|
|
199
|
+
Compact representation of values of different types
|
|
200
|
+
:param v:
|
|
201
|
+
:param max_len: maximal length of the resulting string
|
|
202
|
+
:return: str
|
|
203
|
+
"""
|
|
204
|
+
from .units import Quantity
|
|
205
|
+
from .nptools import array_info_str, np
|
|
206
|
+
import pandas as pd
|
|
207
|
+
|
|
208
|
+
def crop(s, mx):
|
|
209
|
+
if len(s) < mx and '\n' not in s:
|
|
210
|
+
return s
|
|
211
|
+
ss = s.splitlines()
|
|
212
|
+
sfx = ''
|
|
213
|
+
if len(ss) > 1:
|
|
214
|
+
s = ss[0]
|
|
215
|
+
sfx = '...'
|
|
216
|
+
return s.strip()[:(mx - len(sfx))] + sfx
|
|
217
|
+
|
|
218
|
+
if isinstance(v, np.ndarray):
|
|
219
|
+
return array_info_str(v, 1e4)
|
|
220
|
+
|
|
221
|
+
if isinstance(v, pd.DataFrame):
|
|
222
|
+
if len(v.columns.names) > 1:
|
|
223
|
+
columns = ['.'.join(map(str, col)) for col in v.columns]
|
|
224
|
+
else:
|
|
225
|
+
columns = [*map(str, v.columns)]
|
|
226
|
+
return f"{len(v)}[{' '.join(columns)}]"
|
|
227
|
+
|
|
228
|
+
if isinstance(v, pd.Series):
|
|
229
|
+
return f"{len(v)}[{' '.join(v.name)}]"
|
|
230
|
+
|
|
231
|
+
if isinstance(v, (list, tuple)) and all( # repr for list of arrays
|
|
232
|
+
map(lambda x: isinstance(x, np.ndarray), v)):
|
|
233
|
+
ss = ', '.join(map(lambda x: array_info_str(x, 0), v))
|
|
234
|
+
ss = ("[{}]" if isinstance(v, list) else "({})").format(ss)
|
|
235
|
+
elif isinstance(v, dict):
|
|
236
|
+
ss = repr(v if type(v) is dict else dict(v))
|
|
237
|
+
else:
|
|
238
|
+
ss = repr(v)
|
|
239
|
+
|
|
240
|
+
s = crop(ss, max_len)
|
|
241
|
+
try:
|
|
242
|
+
return f"[{len(v)}] {s}" if hasattr(v, '__len__') \
|
|
243
|
+
and not isinstance(v, (Quantity, str)) else s
|
|
244
|
+
except TypeError:
|
|
245
|
+
return s
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def dict_str(d, *, prec: int | str = None, sep=', ', to='=', bracket=None, nested=True):
|
|
249
|
+
"""
|
|
250
|
+
Produce formatted dict str.
|
|
251
|
+
|
|
252
|
+
>>> dct = dict(a=2, b=3.14562342345689, c='string', d={'x':49452.3345523454567});
|
|
253
|
+
>>> dict_str(dct)
|
|
254
|
+
'a=2, b=3.14562342345689, c=string, d={x=49452.334552345455}'
|
|
255
|
+
>>> dict_str(dct, prec=1)
|
|
256
|
+
'a=2, b=3e+00, c=string, d={x=5e+04}'
|
|
257
|
+
>>> dict_str(dct, prec='1f')
|
|
258
|
+
'a=2, b=3.1, c=string, d={x=49452.3}'
|
|
259
|
+
>>> dict_str(dct, prec=2, to=': ', bracket='<>')
|
|
260
|
+
'<a: 2, b: 3.15, c: string, d: <x=49452.33>>'
|
|
261
|
+
|
|
262
|
+
:param d: dict to format
|
|
263
|
+
:param prec: int - precision of general {:.<prec>g} or {:.<prec>} if str
|
|
264
|
+
:param sep: separator between items
|
|
265
|
+
:param to: key: value separator
|
|
266
|
+
:param bracket: None or Sequence of 2 str for opening and closing brackets around the dict
|
|
267
|
+
:param nested: apply recursively to nested dicts values
|
|
268
|
+
:return: resulted string
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def fmt(v):
|
|
272
|
+
if isinstance(v, dict):
|
|
273
|
+
return dict_str(v, bracket=bracket or '{}', prec=prec)
|
|
274
|
+
if isinstance(v, float) and prec is not None:
|
|
275
|
+
return f"{{:.{prec}}}".format(v)
|
|
276
|
+
return str(v)
|
|
277
|
+
|
|
278
|
+
if isinstance(prec, int):
|
|
279
|
+
prec = f"{prec}g"
|
|
280
|
+
|
|
281
|
+
s = sep.join(f"{k}{to}{fmt(v)}" for k, v in d.items())
|
|
282
|
+
if bracket:
|
|
283
|
+
bra, ket = bracket
|
|
284
|
+
s = f"{bra}{s}{ket}"
|
|
285
|
+
return s
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_num_rex = re.compile(r'-?(\d*\.)?\d+([eE]\d+)?')
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def is_num_str(v):
|
|
292
|
+
"""Return True if given str is a representation of some number"""
|
|
293
|
+
return _num_rex.fullmatch(v)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def indent_lines(*lines, indent: int | str = 4) -> str:
|
|
297
|
+
"""Indent all the given lines by the indent str or given number of spaces.
|
|
298
|
+
All the lines provided as the arguments are spilt by '\n', then indented,
|
|
299
|
+
and then joined back by '\n' and return as a single string.
|
|
300
|
+
|
|
301
|
+
:param lines: strings each potentially containing multiple \n separated lines
|
|
302
|
+
:param indent: number of spaces or explicit indentation string
|
|
303
|
+
"""
|
|
304
|
+
if isinstance(indent, int):
|
|
305
|
+
indent = ' ' * indent
|
|
306
|
+
|
|
307
|
+
def iter_lines():
|
|
308
|
+
for line in lines:
|
|
309
|
+
if line:
|
|
310
|
+
yield from line.split('\n')
|
|
311
|
+
|
|
312
|
+
return '\n'.join(indent + _ for _ in iter_lines())
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class Indent:
|
|
316
|
+
_stack: list[Indent] = []
|
|
317
|
+
|
|
318
|
+
__push_depth__: int | None
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def _push_indenter(cls, indenter: Indent):
|
|
322
|
+
indenter.__push_depth__ = indenter.depth
|
|
323
|
+
cls._stack.append(indenter)
|
|
324
|
+
print(f"Pushed {indenter}")
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def _pop_indenter(cls, indenter: Indent):
|
|
328
|
+
"""
|
|
329
|
+
If indenter is being closed:
|
|
330
|
+
- pop it from the stack
|
|
331
|
+
- and return the new "top" None if stack is empty
|
|
332
|
+
Otherwise, return False
|
|
333
|
+
"""
|
|
334
|
+
if indenter.depth == indenter.__push_depth__:
|
|
335
|
+
last = cls._stack.pop()
|
|
336
|
+
if last is not indenter:
|
|
337
|
+
raise RuntimeError
|
|
338
|
+
last.__push_depth__ = None
|
|
339
|
+
return cls._prev_indenter()
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def _stack_id(self):
|
|
344
|
+
"""Return """
|
|
345
|
+
for sid, x in enumerate(self._stack):
|
|
346
|
+
if x is self: break
|
|
347
|
+
else:
|
|
348
|
+
sid = 'Dead'
|
|
349
|
+
return sid
|
|
350
|
+
|
|
351
|
+
def _check_alive(self):
|
|
352
|
+
"""Safety check to avoid using indent out of its context"""
|
|
353
|
+
if self.__push_depth__ is None:
|
|
354
|
+
raise RecursionError("Can't use closed indent context!")
|
|
355
|
+
|
|
356
|
+
@classmethod
|
|
357
|
+
def _prev_indenter(cls):
|
|
358
|
+
if not cls._stack: return None
|
|
359
|
+
return cls._stack[-1]
|
|
360
|
+
|
|
361
|
+
def __init__(self, indent: str | int = 3, *, depth: int = 1, max_depth: int | None = None,
|
|
362
|
+
width: int | None = 80, crop='...', str_func=repr):
|
|
363
|
+
"""
|
|
364
|
+
Create text indenter `context` to represent nested objects.
|
|
365
|
+
Context manager ``__call__`` function generates nested representation
|
|
366
|
+
|
|
367
|
+
*Example:*
|
|
368
|
+
|
|
369
|
+
>>> s = 'Header'
|
|
370
|
+
... with Indent('. ') as ind:
|
|
371
|
+
... s += ind('item1')
|
|
372
|
+
... s += ind('item2')
|
|
373
|
+
... print(s)
|
|
374
|
+
|
|
375
|
+
*Produces:*
|
|
376
|
+
::
|
|
377
|
+
Header
|
|
378
|
+
. item1
|
|
379
|
+
. item2
|
|
380
|
+
|
|
381
|
+
:param indent: level indentation string or number of spaces describing it
|
|
382
|
+
:param width: line width to fit into - crop the rest
|
|
383
|
+
:param crop: str to indicate the cropping
|
|
384
|
+
:param depth: initial indentetion depth
|
|
385
|
+
:param max_depth: nesting level when recursion stops
|
|
386
|
+
:param str_func: function (usually `str` or `repr`) convert objects to str
|
|
387
|
+
"""
|
|
388
|
+
self._line_crop_mark = crop # mark ends of cropped lines
|
|
389
|
+
self._max_depth_mark = "⮷" # mark ends of lines representing depth-collapsed nodes
|
|
390
|
+
self._ind = indent if isinstance(indent, str) else ' ' * indent
|
|
391
|
+
|
|
392
|
+
self._str = str_func
|
|
393
|
+
self.depth = depth - 1
|
|
394
|
+
self.width = None if width is None else max(width, 0)
|
|
395
|
+
|
|
396
|
+
from_prev = ((prev := self._prev_indenter())
|
|
397
|
+
and prev.max_depth is not None
|
|
398
|
+
and (prev.max_depth - prev.depth))
|
|
399
|
+
self.max_depth = (max_depth if from_prev is None else
|
|
400
|
+
from_prev if max_depth is None else
|
|
401
|
+
min(max_depth, from_prev))
|
|
402
|
+
|
|
403
|
+
self._depth_indent = self.depth * self._ind
|
|
404
|
+
self._max_marked = prev and prev._max_marked # track max_depth_mark placement event
|
|
405
|
+
self._push_indenter(self)
|
|
406
|
+
|
|
407
|
+
def __enter__(self):
|
|
408
|
+
self.depth += 1
|
|
409
|
+
self._depth_indent += self._ind
|
|
410
|
+
return self
|
|
411
|
+
|
|
412
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
413
|
+
self.depth -= 1
|
|
414
|
+
self._depth_indent = self._depth_indent[:-len(self._ind)]
|
|
415
|
+
|
|
416
|
+
if prev := self._pop_indenter(self):
|
|
417
|
+
prev._max_marked = self._max_marked # inherit max mark status from the child
|
|
418
|
+
|
|
419
|
+
def __call__(self, obj, nl='\n'):
|
|
420
|
+
"""Create indented representation of the object. By default, prepend a new line."""
|
|
421
|
+
self._check_alive()
|
|
422
|
+
if (mark := self._collapse_depth_mark()) is not None:
|
|
423
|
+
return mark
|
|
424
|
+
|
|
425
|
+
if isinstance(obj, str):
|
|
426
|
+
if is_num_str(obj):
|
|
427
|
+
return f"{nl}'{obj}"
|
|
428
|
+
else:
|
|
429
|
+
s = obj
|
|
430
|
+
else:
|
|
431
|
+
s = self._str(obj)
|
|
432
|
+
|
|
433
|
+
return nl + '\n'.join(map(self._indent_crop, s.split('\n')))
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def remained_width(self):
|
|
437
|
+
return None if self.width is None else self.width - len(self._depth_indent)
|
|
438
|
+
|
|
439
|
+
def _collapse_depth_mark(self):
|
|
440
|
+
"""Check if depth exceeds max_depth and return mark to be printed out or None.
|
|
441
|
+
|
|
442
|
+
This mark is a self._max_depth_mark for the top level being collapsed, and then
|
|
443
|
+
just an empty string for the inner collapsed levels.
|
|
444
|
+
"""
|
|
445
|
+
if self.max_depth is not None and (over := self.depth - self.max_depth) > 0:
|
|
446
|
+
if over == 1 and not self._max_marked:
|
|
447
|
+
self._max_marked = True
|
|
448
|
+
return self._max_depth_mark
|
|
449
|
+
return ''
|
|
450
|
+
self._max_marked = False
|
|
451
|
+
|
|
452
|
+
def _indent_crop(self, line):
|
|
453
|
+
"""
|
|
454
|
+
Add indentation and crop if line exceeds the width.
|
|
455
|
+
|
|
456
|
+
If cropping has removed the max-depth-mark at the end of the line, restore it.
|
|
457
|
+
"""
|
|
458
|
+
if self.width is None: return line
|
|
459
|
+
if len(line := self._depth_indent + line) > self.width: # cropping is needed
|
|
460
|
+
mark = self._max_depth_mark
|
|
461
|
+
restore = line.endswith(mark) and len(mark) # size of the mark being cropped (or 0)
|
|
462
|
+
line = line[:self.width - len(self._line_crop_mark) - restore] + self._line_crop_mark
|
|
463
|
+
if restore: line += mark # restore the cropped mark
|
|
464
|
+
return line
|
|
465
|
+
|
|
466
|
+
def __repr__(self):
|
|
467
|
+
return (f"{self.__class__.__name__}[{self._stack_id}]"
|
|
468
|
+
f"(depth: {self.depth}/{self.max_depth})"
|
|
469
|
+
f"{self._max_marked and self._max_depth_mark or ''}")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def smart_quoted(obj, fnc: Callable = str):
|
|
473
|
+
"""Return string representation of object inserting quotes to avoid ambiguity
|
|
474
|
+
when describing numbers vs strings representing numbers.
|
|
475
|
+
|
|
476
|
+
>>> smart_quoted(10) == "10"
|
|
477
|
+
>>> smart_quoted("2.4e-16") == "'2.4e-16'"
|
|
478
|
+
>>> smart_quoted({'a': 10}) == "{'a': 10}"
|
|
479
|
+
|
|
480
|
+
:param obj: str returned as is, unless it's a representation of a number, then quoted
|
|
481
|
+
:param fnc: function used to convert into str,
|
|
482
|
+
"""
|
|
483
|
+
if isinstance(obj, str):
|
|
484
|
+
return f"'{obj}'" if _num_rex.fullmatch(obj) else obj
|
|
485
|
+
return fnc(obj)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def camel_to_snake(s):
|
|
489
|
+
return re.sub(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", '_', s).lower()
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def repr_nested(obj, width: int | None = None, ind: int | None = None, depth: int | None = None):
|
|
493
|
+
"""
|
|
494
|
+
Create repr for nested object to fit into given line `width`.
|
|
495
|
+
|
|
496
|
+
:param obj: Any object optionally consisting of sub-objects
|
|
497
|
+
:param width: max line width to fit in
|
|
498
|
+
:param ind: indent added to every nested level
|
|
499
|
+
:param depth: maximal nesting depth
|
|
500
|
+
:return: str representation
|
|
501
|
+
"""
|
|
502
|
+
from .short import drop_undef
|
|
503
|
+
|
|
504
|
+
def need_multi_lines(ind, vals):
|
|
505
|
+
return any('\n' in vs for vs in vals) or (
|
|
506
|
+
(w := ind.remained_width) is not None and sum(map(len, vals)) > w
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
with (Indent(**drop_undef(depth=0, indent=ind, width=width, max_depth=depth)) as indent):
|
|
510
|
+
if isinstance(obj, dict):
|
|
511
|
+
values = [str(v) for v in obj.values()]
|
|
512
|
+
if need_multi_lines(indent, values):
|
|
513
|
+
return '\n'.join(f'{k}: {repr_nested(v, width=indent.remained_width)}'
|
|
514
|
+
for k, v in obj.items())
|
|
515
|
+
else: # one-line dict
|
|
516
|
+
return '{{{}}}'.format(', '.join(f'{k}: {v}' for k, v in zip(obj, values)))
|
|
517
|
+
elif isinstance(obj, list):
|
|
518
|
+
values = [smart_quoted(v) for v in obj]
|
|
519
|
+
if need_multi_lines(indent, values):
|
|
520
|
+
return ''.join(repr_nested(indent('- ' + smart_quoted(v, repr)),
|
|
521
|
+
width=indent.remained_width) for v in obj)
|
|
522
|
+
else:
|
|
523
|
+
return f'[{", ".join(values)}]'
|
|
524
|
+
else:
|
|
525
|
+
return smart_quoted(obj, repr)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
#
|
|
529
|
+
# def repr_nested(obj, width, indent, level: int = None):
|
|
530
|
+
# """
|
|
531
|
+
# Create repr for nested object to fit into give line `width`.
|
|
532
|
+
#
|
|
533
|
+
# :param obj: Any object optionally consisting of sub-objects
|
|
534
|
+
# :param width: max line width to fit in
|
|
535
|
+
# :param indent: indent added to every nested level
|
|
536
|
+
# :param level: Not supported currently
|
|
537
|
+
# :return: str representation
|
|
538
|
+
# """
|
|
539
|
+
# width -= len(indent)
|
|
540
|
+
#
|
|
541
|
+
# if width < 12 or level is not None and level < 0:
|
|
542
|
+
# if hasattr(obj, '__len__'):
|
|
543
|
+
# obj = f'{type(obj).__name__}(×{len(obj)} items)'
|
|
544
|
+
# else:
|
|
545
|
+
# obj = str(obj).split('\n')[0][:width] + '...'
|
|
546
|
+
# return obj
|
|
547
|
+
#
|
|
548
|
+
# items = None
|
|
549
|
+
# if is_dict := isinstance(obj, dict):
|
|
550
|
+
# items = [f"{k}: " + repr_nested(v, width, indent) for k, v in obj.items()]
|
|
551
|
+
# elif isinstance(obj, list):
|
|
552
|
+
# items = [repr_nested(v, width, indent) for v in obj]
|
|
553
|
+
#
|
|
554
|
+
# if items is not None: # dict or list
|
|
555
|
+
# concat = ''
|
|
556
|
+
# sep = f'\n{indent}'
|
|
557
|
+
# for s in items:
|
|
558
|
+
# if len(s) > width or '\n' in s or len(concat := concat + s) > width:
|
|
559
|
+
# if is_dict:
|
|
560
|
+
# return f'{sep}'.join(items)
|
|
561
|
+
# else:
|
|
562
|
+
# return f'{sep}- '.join(['', *items]).replace('\n', '\n' + indent)
|
|
563
|
+
#
|
|
564
|
+
# return ('{{{}}}' if is_dict else '[{}]').format(', '.join(items))
|
|
565
|
+
# elif not isinstance(obj, str):
|
|
566
|
+
# obj = str(obj)
|
|
567
|
+
# return obj.replace('\n', '\n' + indent)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def plural(word):
|
|
571
|
+
"""Make plural form of a noun"""
|
|
572
|
+
# Check if word is ending with s,x,z or is
|
|
573
|
+
# ending with ah, eh, ih, oh,uh,dh,gh,kh,ph,rh,th
|
|
574
|
+
if re.search('[sxz]$', word) or re.search('[^aeioudgkprt]h$', word):
|
|
575
|
+
# Make it plural by adding es in end
|
|
576
|
+
return re.sub('$', 'es', word)
|
|
577
|
+
# Check if word is ending with ay,ey,iy,oy,uy
|
|
578
|
+
elif re.search('[aeiou]y$', word):
|
|
579
|
+
# Make it plural by removing y from end adding ies to end
|
|
580
|
+
return re.sub('y$', 'ies', word)
|
|
581
|
+
# In all the other cases
|
|
582
|
+
else:
|
|
583
|
+
# Make the plural of word by adding s in end
|
|
584
|
+
return word + 's'
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
ALPHABETS = {
|
|
588
|
+
36: "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def hash_str(s, sz: int = None, *, base: int | str = 16):
|
|
593
|
+
"""For given string build md5 hash string of requested length.
|
|
594
|
+
|
|
595
|
+
By default, result is 16-base (HEX) string.
|
|
596
|
+
Also, 36 based '0123456789abcdefghijklmnopqrstuvwxyz' is supported,
|
|
597
|
+
|
|
598
|
+
:param s: string to hash
|
|
599
|
+
:param sz: length of the resulting hash string cropped from the right
|
|
600
|
+
:param base: alphabet or base of convertion of md5 bytecode (default 16, may be 36)
|
|
601
|
+
:return: hash str
|
|
602
|
+
"""
|
|
603
|
+
from hashlib import md5
|
|
604
|
+
h = md5(s.encode())
|
|
605
|
+
if base == 16:
|
|
606
|
+
s = h.hexdigest()
|
|
607
|
+
else:
|
|
608
|
+
if isinstance(base, int):
|
|
609
|
+
if not (alphabet := ALPHABETS.get(base, None)):
|
|
610
|
+
if (max_base := max(ALPHABETS)) < base:
|
|
611
|
+
raise ValueError(f"Invalid {base=}")
|
|
612
|
+
alphabet = ALPHABETS[max_base][:base]
|
|
613
|
+
s = int_to_string(int.from_bytes(h.digest()), alphabet=alphabet, padding=sz)
|
|
614
|
+
return s[-(sz if sz else 0):]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def int_to_string(number: int, alphabet: str | int, padding: int | None = None) -> str:
|
|
618
|
+
"""
|
|
619
|
+
Convert a number to a string, using the given alphabet.
|
|
620
|
+
|
|
621
|
+
The output has the most significant digit first.
|
|
622
|
+
"""
|
|
623
|
+
if not isinstance(alphabet, str):
|
|
624
|
+
alphabet = ALPHABETS[alphabet]
|
|
625
|
+
alpha_len = len(alphabet)
|
|
626
|
+
|
|
627
|
+
output = ""
|
|
628
|
+
while number:
|
|
629
|
+
number, digit = divmod(number, alpha_len)
|
|
630
|
+
output += alphabet[digit]
|
|
631
|
+
if padding:
|
|
632
|
+
remainder = max(padding - len(output), 0)
|
|
633
|
+
output = output + alphabet[0] * remainder
|
|
634
|
+
return output[::-1]
|
|
635
|
+
|