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/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
+