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/filesproc.py ADDED
@@ -0,0 +1,1046 @@
1
+ """ Module contains utilities for automation of files processing.
2
+ """
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Tuple, Union, Iterable, Callable, Literal, Type, Any, Generator
10
+
11
+ from .datatools import unique as unique_filter
12
+ from .short import as_iter
13
+ from .events import timed, Timer
14
+ import logging
15
+
16
+ PathT = Union[Path, str]
17
+ UNDEF = object()
18
+
19
+
20
+ def represents_path(v: PathT, required='/\\', max_len=256):
21
+ """Checks if given object MAY represent path:
22
+ 1. if its `Path` instance, or
23
+ 2. if is a str which contains at least one of the `smb` characters
24
+ :param v: object to test
25
+ :param smb: string with collection of path-indicating characters
26
+ """
27
+ if isinstance(v, str):
28
+ if required:
29
+ for s in required:
30
+ if s in v: break
31
+ else:
32
+ return False
33
+
34
+ if len(v) > max_len or re.search(r'[\t\n?^@&\'\"]|\s{2,}', v):
35
+ return False
36
+ return True
37
+ return isinstance(v, Path)
38
+
39
+
40
+ def package_folder(dotted: str):
41
+ """
42
+ Given the `import name` of a package or module
43
+ return folder of its location.
44
+
45
+ :param dotted: dotted name of the package or module
46
+ :return: Path object of its folder
47
+ Example:
48
+ ::
49
+ package_folder('iad.io')
50
+ PosixPath('/home/user/code/algodev/example/path')
51
+
52
+ :param p:
53
+ :return:
54
+ """
55
+ from importlib.util import find_spec
56
+ spec = find_spec(dotted)
57
+ return Path(spec.origin).parent
58
+
59
+
60
+ def root_cropper(root: PathT, method: Literal['crop', 'replace', 'relative'] = 'replace',
61
+ *, out_str=str) -> Callable[[PathT], PathT]:
62
+ """
63
+ Create a function that crops a root folder from path.
64
+ May choose from those methods:
65
+ * crop - just removes beginning of the path str with length of root.
66
+ Use if sure that all the paths include root
67
+ * replace - use ``str.replace`` to find and remove (once) the root substring.
68
+ Rise error if not found, so less dangerous but slower (~x2)
69
+ Still makes assumptions about normalization of paths.
70
+ * relative - use Path.relative_to to find relative path to the root.
71
+ Safest option, but much slower (~x50 than crop)
72
+ :param root: path to a root folder
73
+ :param out_str: if True the function would return str otherwise Path object
74
+ :param method: supported 'crop' | 'replace' | 'relative'
75
+
76
+ :return: function object receiving path and returning str of relative to root
77
+ """
78
+ root = normalize(root)
79
+ if method == 'relative':
80
+ return \
81
+ (lambda s: str(Path(s).relative_to(root))) if out_str else \
82
+ (lambda s: Path(s).relative_to(root))
83
+
84
+ root = str(root)
85
+ if not root.endswith(os.sep):
86
+ root += os.sep
87
+
88
+ if method == 'replace':
89
+ return out_str and \
90
+ (lambda s: s.replace(root, '', 1)) if out_str else \
91
+ (lambda s: Path(s.replace(root, '', 1)))
92
+ if method == 'crop':
93
+ root_len = len(root)
94
+ return \
95
+ (lambda s: s[root_len:]) if out_str else \
96
+ (lambda s: Path(s[root_len:]))
97
+
98
+ raise NotImplementedError(f'Not supported path cropping {method=}')
99
+
100
+
101
+ def root_adder(root, *, as_str=True, out_str: bool = None) -> Callable[[PathT], PathT]:
102
+ """
103
+ Create a function that prepends root folder to a relative path.
104
+ :param root: path to a root folder
105
+ :param as_str: use root as string and append using string manipulation
106
+ alternatively uses pathlib (~x20 slower, but more robust)
107
+ :param out_str: True of False for the func return str.
108
+ if None follows as_str.
109
+
110
+ :return: function object receiving path and returning str of relative to root
111
+ """
112
+ out_str = as_str if out_str is None else out_str
113
+ root = normalize(root)
114
+ if as_str:
115
+ root = str(root)
116
+ if not root.endswith(os.sep):
117
+ root += os.sep
118
+ return \
119
+ (lambda s: root + s) if out_str else \
120
+ (lambda s: Path(root + s))
121
+ else:
122
+ return \
123
+ (lambda s: str(root / s)) if out_str else \
124
+ (lambda s: root / s)
125
+
126
+
127
+ def compress_numpy_file(file, clean=True):
128
+ """ Convert numpy file into compressed npz form (loss-less)
129
+ :param clean: remove the sources
130
+ """
131
+ import numpy as np
132
+ from iad.io.imread import imread
133
+ file = Path(file)
134
+ np.savez_compressed(file.with_suffix('.npz'), **imread(file, out=dict))
135
+ if clean:
136
+ os.remove(file)
137
+
138
+
139
+ def prepare_parent_folder(file_or_path, mode=0o777):
140
+ """
141
+ Make sure given folder (or parent folder of given file) exists.
142
+
143
+ :param file_or_path:
144
+ :param mode: mode for the _new_ folder
145
+ """
146
+ normalize(file_or_path).parent.mkdir(mode=mode, exist_ok=True)
147
+
148
+
149
+ def empty_folder(folder_path):
150
+ """ Create empty folder if folder in folder_path not exist or clean clean existing one.
151
+
152
+ :param folder_path: resulting empty folder
153
+ """
154
+ from shutil import rmtree
155
+ import stat
156
+
157
+ if os.path.exists(folder_path):
158
+ rmtree(folder_path)
159
+ os.makedirs(folder_path)
160
+ os.chmod(folder_path, stat.S_IWUSR)
161
+
162
+
163
+ def valid_folder(folder):
164
+ """Normalize folder and throw FileExistsError if path not exists"""
165
+ folder = normalize(folder, Path)
166
+ if folder.is_dir() and folder.exists():
167
+ return folder
168
+ raise FileExistsError(f'Folder {folder} does not exist!')
169
+
170
+
171
+ def rename_files_by_ref(files: Union[Iterable, str], ref_files: Union[Iterable, str], *,
172
+ pat=r'_([\d]+)\.[\w]+$',
173
+ ref_pat=None, rename=True) -> List[Tuple[str, str]]:
174
+ """ Rename files by changing their ids as appear in /some_file_name_<id>.ext
175
+ by using ids from another list of files.
176
+ Match is done by lexicographical sort!
177
+ :param files: iterable over files' names to rename or glob pattern
178
+ :param ref_files: iterable over files' names to use as reference or glob pattern
179
+ :param pat: regexp pattern with a single group to replace in the original files
180
+ by default - '_<numbers>.ext'
181
+ :param ref_pat: pattern with a single group to replace with from the reference.
182
+ if None - same as pat
183
+ :param rename: if False don't really change the files
184
+ :return list of tuples [(old, new)]
185
+ """
186
+ from glob import glob
187
+ import re
188
+
189
+ if isinstance(files, str):
190
+ files = glob(files)
191
+ if isinstance(ref_files, str):
192
+ ref_files = glob(ref_files)
193
+
194
+ if len(files) != len(ref_files):
195
+ raise ValueError(f'Files lists are of different size: {len(files)} != {len(ref_files)}')
196
+
197
+ pat = re.compile(pat)
198
+ ref_pat = pat if ref_pat is None else re.compile(ref_pat)
199
+
200
+ def replace_id(nm1, nm2):
201
+ id1 = pat.search(nm1).groups[0]
202
+ id2 = ref_pat.search(nm2).groups[0]
203
+ return nm1.replace(id1, id2)
204
+
205
+ old_new_pairs = [(f, replace_id(f, r)) for f, r in
206
+ zip(sorted(files), sorted(ref_files))]
207
+ if rename:
208
+ for old_new in old_new_pairs:
209
+ os.rename(*old_new)
210
+ return old_new_pairs
211
+
212
+
213
+ def normalize(path: PathT, out: Type[Path] | Type[str] | None = Path):
214
+ """
215
+ Translate path into canonical form expanding user but not resolving links.
216
+ Mainly created since ``pathlib``'s resolve does path normalization
217
+ AND link resolution, which in some cases is not desirable.
218
+
219
+ Example:
220
+ >>> normalize('~'), normalize('~', out=None), normalize(Path('~'), out=None)
221
+ Path('/home/user'), '/home/user', Path('/home/user')
222
+
223
+ :param path: str or Path object
224
+ :param out: output type, None - keep as input
225
+ :return: Path | str
226
+ """
227
+ norm = os.path.normpath(os.path.expanduser(path))
228
+ if out is None and not isinstance(path, str):
229
+ out = type(path)
230
+ return out(norm)
231
+
232
+
233
+ def transform_path(full_path: str | Path, *, new_root: str = None, new_ext: str = None,
234
+ old_root: Path | str = None) -> str:
235
+ """
236
+ Transforming the input path based on the supplied parameters.
237
+ The user must supply BOTH the full path and the old root.
238
+ The user must supply AT LEAST one of the two:
239
+ 1. new root - this root will replace the old root.
240
+ 2. new_ext - this extension will replace the old extension
241
+
242
+ Example:
243
+ >>> supplied_full_path = f'{old_root}/.../file.old_ext'
244
+ >>> transformed = transform_path(supplied_full_path, new_root, new_ext, old_root)
245
+
246
+ The new path structure is:
247
+ >>> f'{new_root}/.../file.{new_ext}'
248
+
249
+ :param full_path: desired file absolute full_path
250
+ :param new_root: desired root
251
+ :param new_ext: desired extension
252
+ :param old_root: old root
253
+
254
+ :return: post transformation string that altered by the user desired paramaters
255
+ """
256
+ assert full_path, 'full_path must supplied'
257
+ assert new_root or new_ext, \
258
+ 'New path must differ in at least one segment, overwrite is not supported'
259
+ if new_root: assert old_root, 'Root replacements demands old root to be supplied.'
260
+
261
+ if new_ext and '.' not in new_ext:
262
+ new_ext = f'.{new_ext}'
263
+
264
+ new_path = str(Path(full_path).with_suffix(new_ext)) if new_ext else str(full_path)
265
+
266
+ if new_root:
267
+ if old_root != Path('/'): # if there is an old root
268
+ new_path = new_path.replace(Path(old_root).__str__(), Path(new_root).__str__())
269
+ else: # special case where old root is '/' - include the new root at the start
270
+ new_path = Path(new_root).joinpath(Path(new_path[1:]))
271
+ return new_path
272
+
273
+
274
+ class Locator:
275
+ """
276
+ Manages multiple locations in the file system and provides methods to find and validate them.
277
+
278
+ Each of the locations can be defined:
279
+ - explicitely as a path,
280
+ - as environment variable
281
+ - as another Locator
282
+
283
+ Main functionality resides in ``Locator.existing_folders()`` method.
284
+ By default it returns iterator over actually existing folders at the time of the call.
285
+ """
286
+ _PT = Union[PathT, 'Locator']
287
+ _PsT = Iterable[_PT] | _PT
288
+
289
+ _log = logging.getLogger('env')
290
+
291
+ AlarmTypes = bool | Callable[[str], Any] | Exception | Type[Exception]
292
+
293
+ @staticmethod
294
+ def _win_bug_safe(safe: bool):
295
+ if os.name == 'nt' and safe:
296
+ Locator._log.info(f'Disabling {safe=} mode (not supported in Windows)')
297
+ return False
298
+ return safe
299
+
300
+ @staticmethod
301
+ def _validate_alarm_type(alarm: AlarmTypes) -> AlarmTypes:
302
+ if (isinstance(alarm, type) and not issubclass(alarm, Exception)
303
+ or not isinstance(alarm, (bool, Exception, Callable))):
304
+ raise TypeError(f"Invalid {type(alarm)=}")
305
+
306
+ if os.name == 'nt' and alarm:
307
+ Locator._log.info(f'Disabling validation {alarm=} (not supported in Windows)')
308
+ return False
309
+ return alarm
310
+
311
+ @staticmethod
312
+ def _normalize(p: _PT) -> _PT:
313
+ """If provided location is path and not ``Locator`` normilize its representation"""
314
+ return p if isinstance(p, Locator) else normalize(p)
315
+
316
+ def __init__(self, *folders: PathT | Locator | None, envar: str = None, sub: PathT = None, order='EIA',
317
+ alarm: AlarmTypes = False, safe: bool | None = None, timeout=.5, caching=True):
318
+ """
319
+ Define locations to check for usually known before the run-time.
320
+
321
+ Locations are searched in order is defined by the string of following letters denoting:
322
+ - *E*: folder from the Env variable
323
+ - *I*: Internal fallback folders
324
+ - *A*: Additional folders
325
+
326
+ Every letter must be used no more than once.
327
+ Valid orders: '*IA*', '*E*', '*AIE*'.
328
+
329
+ Non-responsiove File System
330
+ ---------------------------
331
+
332
+ Sometimes a network mount may become non-responsive freezing file system queries to its content.
333
+
334
+ (*Validation and safety mechanism is currently only supported in Linux.*)
335
+
336
+ Validation
337
+ ==========
338
+
339
+ ``Locator.validate()`` allows to discover such situation and remove problematic
340
+ folder from the locator or raise exception depending on the `alarm` argument:
341
+ - ``False`` - don't validate
342
+ - ``True`` - remove problematic folder silently
343
+ - ``Exception`` - given exception object is given is raisen
344
+ - ``Exception class`` - construct an object of this type with info message and raise
345
+ - ``Callable`` - called with info message
346
+
347
+ Safely Checking Existence
348
+ =========================
349
+
350
+ It's an alternative meachanism based on a non-hanging alternative for `Path.is_dir`.
351
+
352
+ It can be activated by setting `safe`=``True`` in constructor, or as argument
353
+ in some method like ``existing``, ``first_existing``.
354
+
355
+ I not set explicitely, default state of ``Locator.safe`` depends on validation:
356
+ - ``Locator(...,alarm,safe=None)`` sets `safe = bool(alarm)`
357
+ - ``Locator.validate(...,alarm,safe=None)`` resets `safe = not alarm`
358
+
359
+ :param folders: list of paths, or ``None`` - empty strings and ``None`` are ignored!
360
+ :param envar: environment variable with search folder
361
+ :param default: default search folder
362
+ :param sub: sub-path relative to those defined in envar and defaults
363
+ :param order: order of searching locations
364
+ :param alarm: validate accessibility of all the deinfed folders
365
+ :param safe: (de)activate slower but not not-hanging existence chek of folders
366
+ :param timeout: timeout in seconds for safe and validation operations
367
+ """
368
+ # ---- safe checking mechanism ---
369
+ self.alarm = self._validate_alarm_type(alarm)
370
+ self.safe = bool(self.alarm) if safe is None else safe
371
+ self._cache = {}
372
+ self.timeout = timeout
373
+ self.set_check_opt(caching=caching)
374
+
375
+ # ---- core logic ------------
376
+ self.envar = envar
377
+ self.folders = list(self._norm_folders(folders)) # remove possible Nones, etc
378
+ self.order = self._valid_order(order)
379
+ self._initial = {'envar': envar, 'folders': self.folders}
380
+
381
+ self._show_existing = False # control if __repr__ does not checks firts_existing
382
+
383
+ if isinstance(sub, str):
384
+ if (sub := sub.strip()) == '':
385
+ raise ValueError(f"Invalid value of argument {sub=}!")
386
+ sub = Path(sub)
387
+ if sub and sub.is_absolute():
388
+ raise ValueError(f"Argument {sub=} must be relative!")
389
+ self.sub = sub
390
+
391
+ def set_check_opt(self, *, caching: bool | None | Literal['clear'] = None,
392
+ safe: bool = None, timeout=None) -> bool | None:
393
+ """
394
+ Set options for safe checking of "non-reponding" path mechanism
395
+ used in `validate` and `existing` methods.
396
+
397
+ (``None`` argument leaves the corresponding option unchanged)
398
+
399
+ A path can be checked by 3 different methods:
400
+ 1. ``Path.is_dir`` - not a safe method, the system call may freeze under certain conditions
401
+ 2. *safe* check (in another process killed after timeout) - expensive (hundreds ms)
402
+ 3. retrive *cached* results of previous safe check - fast, but not upto date
403
+
404
+ :param safe: (de)activate safe `existence` checks
405
+ :param caching: bool - enable or disable, or 'clear' (also enables)
406
+ :param timeout: indicator of "non-responsiveness"
407
+ """
408
+ if safe is not None:
409
+ self.safe = safe
410
+
411
+ if timeout is not None:
412
+ self.timeout = timeout
413
+
414
+ if caching == 'clear' or caching is True and self._cache is None:
415
+ self._cache = {}
416
+ elif caching is False:
417
+ self._cache = None
418
+
419
+ def _check_path(self, path, timeout=None, safe=None):
420
+ if safe is None:
421
+ safe = self.safe
422
+ if not safe:
423
+ return Path.is_dir(path)
424
+
425
+ if timeout is None:
426
+ timeout = self.timeout
427
+
428
+ path = str(path)
429
+ caching = self._cache is not None
430
+
431
+ if not caching or UNDEF is (res := self._cache.get(path, UNDEF)):
432
+ res = check_path(path, timeout=timeout, log=self._log.info)
433
+ caching and self._cache.setdefault(path, res)
434
+ elif caching:
435
+ self._log.debug(f'check cache hit: `{path}`')
436
+ return res
437
+
438
+ @property
439
+ def safe(self):
440
+ return self._safe
441
+
442
+ @safe.setter
443
+ def safe(self, val: bool):
444
+ self._safe = self._win_bug_safe(val)
445
+
446
+ def reconfigure(self, *folders: PathT | Locator, envar: str = UNDEF, order=None):
447
+ """
448
+ Reset configuration of the locator by replacing folders AND/OR environment variable IF provided.
449
+
450
+ Usefull for alter locations for objects already initialized by this locator object without
451
+ reinstantiation or reconfiguring them.
452
+
453
+ :param folders: new folders
454
+ :param envar: new name of environment variable
455
+ :param order: new order
456
+ """
457
+ if envar is not UNDEF:
458
+ self.envar = envar
459
+ if folders:
460
+ self.folders = list(self._norm_folders(folders))
461
+ if order:
462
+ self.order = self._valid_order(order)
463
+
464
+ @staticmethod
465
+ def _norm_folders(folders):
466
+ return map(Locator._normalize, filter(None, folders))
467
+
468
+ def validate(self, alarm: AlarmTypes | None = None, *, safe=None, timeout=None) -> Locator:
469
+ """
470
+ Validate locator by removing invalid problematic folders and envar (or raise ),
471
+ depending on the `alarm` argument:
472
+ - ``None`` - follow ``self.alarm`` value
473
+ - ``False`` - don't validate
474
+ - ``True`` - remove problematic folder silently
475
+ - ``Exception`` - given exception object is given is raisen
476
+ - ``Exception class`` - construct an object of this type with info message and raise
477
+ - ``Callable`` - called with info message
478
+
479
+ Generally, since validation makes *safe* queries unnecessary, by default it sets \n
480
+ ``self.safe=False`` (`if alarm` and `safe==None`)
481
+
482
+ :param alarm: optionally (if not ``None``) used instead of ``self.alarm``
483
+ :param safe: optionally (if not ``None``) set ``self.safe`` to this value.
484
+ :param timeout: optional (if not ``None``) to use insted own timeouts
485
+ :return: ``self``
486
+ """
487
+ if alarm is None:
488
+ alarm = self.alarm
489
+ else:
490
+ self._validate_alarm_type(alarm)
491
+
492
+ if safe is None: # default beghaviour
493
+ safe = False if alarm else self.safe # disable if validated or leave untoched
494
+ self.safe = safe
495
+
496
+ if not alarm:
497
+ return self
498
+
499
+ def _timeout(base):
500
+ return base if timeout is None else timeout
501
+
502
+ def rmv_msg(obj):
503
+ f"Removing from {self} \n\t not responding for {_timeout(self.timeout)}sec {obj}"
504
+
505
+ def _validate(folder: Path | Locator) -> Path | Locator | None:
506
+ """Return validated locator or folder if OK, or ``None`` or raise depending on `validate`. """
507
+ nonlocal alarm # otherwise (..., alarm=alarm) can't work
508
+ if isinstance(folder, Locator): # if locator - recreate it recursively validated
509
+ return Locator(*folder.folders, envar=folder.envar, sub=folder.sub, order=folder.order,
510
+ safe=folder.safe, alarm=alarm, timeout=_timeout(folder.timeout))
511
+
512
+ if check_path(
513
+ folder / self.sub if self.sub else folder,
514
+ timeout=_timeout(self.timeout)
515
+ ) is not None: return folder # folder is OK
516
+
517
+ # --------------- now process the failure --------------
518
+ if alarm is True or isinstance(alarm, Callable):
519
+ self._log.warning(msg := rmv_msg(folder))
520
+ alarm is not True and alarm(msg)
521
+ return None
522
+
523
+ if isinstance(alarm, type) and issubclass(alarm, Exception):
524
+ alarm = Exception(f"{folder=} is not accessible!")
525
+ if isinstance(alarm, Exception):
526
+ raise alarm
527
+ raise TypeError(f"Invalid {type(alarm)}")
528
+
529
+ self.folders = list(filter(_validate, self.folders))
530
+
531
+ if self.env_path and check_path(
532
+ self.env_path,
533
+ timeout=_timeout(self.timeout)
534
+ ) is None: # invalid
535
+ self._log.warning(rmv_msg(f"envar ${self.envar}={self.env_path}"))
536
+ self.envar = None # remove it
537
+
538
+ return self
539
+
540
+ def __repr__(self):
541
+ sub = f"|'/{self.sub}'" if self.sub else ''
542
+
543
+ INDT = '\n\t\t\t'
544
+ folders = list(map(str, self.folders))
545
+ n = len(folders)
546
+ if any('\n' in f for f in folders): # multiline already - prepend \t to all the lines
547
+ folders = INDT.join(f.replace('\n', INDT) for f in folders)
548
+ else:
549
+ sep = INDT if n > 1 or sum(map(len, folders)) + 2 * n > 80 else ', '
550
+ folders = sep.join(folders)
551
+ folders = f'folders:{INDT}{folders}' if sep == INDT else f"[{folders}]"
552
+ env = f"${self.envar}={self.env_path or ''}" if self.envar else ''
553
+ if self._show_existing:
554
+ found = '🗸' if self.first_existing() else '❌'
555
+ else:
556
+ found = ''
557
+
558
+ safe = ('☔' if self.safe else '🌂') if self.alarm else ('☂' if self.safe else '')
559
+ return f"{type(self).__name__}<{self.order}{sub}>{env} {folders}{found}{safe}"
560
+
561
+ def repr(self, existing=True):
562
+ """Allows explicitely request expensive show of existing folder"""
563
+ keep = self._show_existing
564
+ if existing is not None:
565
+ self._show_existing = existing
566
+ res = self.__repr__()
567
+ self._show_existing = keep
568
+ return res
569
+
570
+ def __bool__(self):
571
+ return bool(self.folders or self.envar)
572
+
573
+ def __add__(self, folders: _PsT) -> Locator:
574
+ return Locator(*self.folders, *as_iter(folders), envar=self.envar, sub=self.sub)
575
+
576
+ def __iadd__(self, folders: _PsT):
577
+ self.folders.extend(self._norm_folders(as_iter(folders)))
578
+ return self
579
+
580
+ def _rm_folders_iter(self, folders: _PsT):
581
+ """Return iterator over folders remained in ``self`` after removing given folders.
582
+
583
+ If ``self.folders`` contain ``Locator`` objects, they are replaced by
584
+ a copies with given folders removed from them as well.
585
+ """
586
+ folders = set(self._norm_folders(as_iter(folders)))
587
+ for p in self.folders:
588
+ if isinstance(p, Locator):
589
+ p = p - folders
590
+ elif p in folders:
591
+ continue
592
+ yield p
593
+
594
+ def __sub__(self, folders: _PsT) -> Locator:
595
+ return Locator(*self._rm_folders_iter(folders), envar=self.envar, sub=self.sub)
596
+
597
+ def __isub__(self, folders: _PsT):
598
+ self.folders = list(self._rm_folders_iter(folders))
599
+ return self
600
+
601
+ def __truediv__(self, sub: PathT) -> Locator:
602
+ """Create new locator with a more deep sub relative to the former sub.
603
+ The path in the divisor is an extension to the current sub.
604
+ Locator("/tmp", sub="one") / "two" == Locator("/tmp", sub="one/two")
605
+ """
606
+ if isinstance(sub, str) and not (sub := sub.strip()):
607
+ return self
608
+ if self.sub:
609
+ sub = Path(self.sub) / sub
610
+ return Locator(*self.folders, envar=self.envar, sub=sub)
611
+
612
+ @staticmethod
613
+ def _valid_order(order):
614
+ if not isinstance(order, str):
615
+ raise TypeError(f'Expected str argument {order=}')
616
+ order_set, codes = set(order := order.upper()), set('AEI')
617
+ if not order_set or len(order_set) < len(order) or order_set - codes:
618
+ raise f"Invalid {order=}. Use {codes=} no more than once each."
619
+ return order
620
+
621
+ def _locations_sequence(self, *folders: PathT, order=None) -> Generator[Path]:
622
+ """Generator of locations in certain order coded using those letters:
623
+
624
+ - *E*: folder from the Env variable
625
+ - *I*: Internal fallback folders
626
+ - *A*: Additional folders
627
+
628
+ Every letter must be used no more than once.
629
+ Valid orders: '*IA*', '*E*', '*AIE*'.
630
+
631
+ **Note!** The additional folders are not appended by ``sub``!
632
+
633
+ :param order: if provided overrides default order
634
+ :param *folders: additional locations to try
635
+ """
636
+
637
+ def prep(p, sub=None):
638
+ if isinstance(p, str):
639
+ p = Path(p)
640
+ if sub:
641
+ p = p / sub
642
+ return p
643
+
644
+ def folders_gen(fs, sub=None):
645
+ for i, loc in enumerate(fs):
646
+ if isinstance(loc, Locator):
647
+ yield from loc._locations_sequence(order=order)
648
+ else:
649
+ yield prep(loc, sub)
650
+
651
+ def env_gen(sub):
652
+ if enval := self.env_path:
653
+ yield prep(enval, sub)
654
+
655
+ # ---------------------------------------------
656
+ order = self._valid_order(order or self.order)
657
+ for src in order:
658
+ yield from (
659
+ env_gen(self.sub) if src == 'E' else
660
+ folders_gen(self.folders, self.sub) if src == 'I' else
661
+ folders_gen(self._norm_folders(folders))
662
+ )
663
+
664
+ @property
665
+ def env_path(self):
666
+ return self.envar and os.getenv(self.envar)
667
+
668
+ def defined(self, *folders, order=None, unique=True) -> Generator[Path]:
669
+ """
670
+ Generator of all the defined (not necessary existing!) locations.
671
+
672
+ Order is defined by the string of following letters denoting:
673
+
674
+ - *E*: folder from the Env variable
675
+ - *I*: Internal fallback folders
676
+ - *A*: Additional folders
677
+
678
+ Every letter must be used no more than once.
679
+ Valid orders: '*IA*', '*E*', '*AIE*'.
680
+
681
+ **Note!** The additional folders are not appended by ``sub``!
682
+
683
+ **Note!**
684
+ Current implementation treats ``Locator`` objects found amoung `folders`
685
+ as source of folders without distinguishing thier internal ``envar``.
686
+
687
+ Therefore environment-defied folders will be intermixed in their order
688
+ within other folders defined by the internal locator.
689
+
690
+ However, the `order` argument is still passed recursively
691
+ to order internal locators.
692
+
693
+ :param order: if provided overrides default order
694
+ :param fail: if ``True`` raise ``NotADirectoryError`` for any not existing folder
695
+ :param *folders: additional locations to try
696
+ :param unique: if True filters out repeated appearences of same folders
697
+ """
698
+ seq = self._locations_sequence(*folders, order=order)
699
+ return unique_filter(seq) if unique else seq
700
+
701
+ def existing(self, *folders, order=None, unique=True, fail=False, safe=None) -> Generator[Path]:
702
+ """
703
+ Generator of ONLY existing folders checked in the given order.
704
+
705
+ Order is defined by the string of following letters denoting:
706
+
707
+ - *E*: folder from the Env variable
708
+ - *I*: Internal fallback folders
709
+ - *A*: Additional folders
710
+
711
+ Every letter must be used no more than once.
712
+ Valid orders: '*IA*', '*E*', '*AIE*'.
713
+
714
+ **Note!** The additional folders are not appended by ``sub``!
715
+
716
+ **Note!**
717
+ Current implementation treats ``Locator`` objects found amoung `folders`
718
+ as source of folders without distinguishing thier internal ``envar``.
719
+
720
+ Therefore environment-defied folders will be intermixed in their order
721
+ within other folders defined by the internal locator.
722
+
723
+ However, the `order` argument is still passed recursively
724
+ to order internal locators.
725
+
726
+ :param order: if provided overrides default order
727
+ :param fail: if ``True`` raise ``NotADirectoryError`` for any not existing folder
728
+ :param *folders: additional locations to try
729
+ :param unique: if True filters out repeated appearences of same folders
730
+ :param safe: override `safe` flag of the oject for this call
731
+ """
732
+ seq = map(normalize, self._locations_sequence(*folders, order=order))
733
+
734
+ for folder in unique_filter(seq) if unique else seq:
735
+ if ret := self._check_path(folder, safe=safe):
736
+ yield folder
737
+ elif fail:
738
+ raise NotADirectoryError(f"{'Not-responsding' if ret is None else 'Missing'} {folder=}")
739
+
740
+ def first_existing(self, *folders: PathT, order=None, safe=None) -> Path | None:
741
+ """
742
+ Return first existing folder in the given order.
743
+
744
+ Order is defined by the string of following letters denoting:
745
+ - *E*: folder from the Env variable
746
+ - *I*: Internal fallback folders
747
+ - *A*: Additional folders
748
+
749
+ **Notice!** Nones and '' are filtered out from `folders` before the processing!
750
+
751
+ :param folders: additional "fall back" options to check
752
+ :param order: optionally override order of searching the locations
753
+ :param safe: override `safe` flag of the oject for this call
754
+ """
755
+ for folder in self.existing(*filter(None, folders), order=order, safe=safe):
756
+ return folder
757
+ return None
758
+
759
+ def find_file_iter(self, name: PathT, *, order=None, safe=None) -> Generator[PathT]:
760
+ """Generator of all the found paths to the file with given name.
761
+
762
+ :param name: the last parts of the file path
763
+ :param order: if provided ('EIA' codes) use insted of the current order
764
+ :param safe: override `safe` flag of the oject for this call
765
+ """
766
+ for folder in self.existing(order=order, safe=safe):
767
+ full_path = folder / name
768
+ if full_path.exists():
769
+ yield full_path
770
+
771
+ def first_file(self, name, *, order=None) -> None | Path:
772
+ """
773
+ Search for the first occurance of the file in all the locations.
774
+
775
+ Example:
776
+ >>> Locator('/path1', '/path2/sub').first_file('par/name.ext')
777
+ # Path('/path2/sub/par/name.ext') - first location found
778
+
779
+ :param name: file name with possible its parents folders parts
780
+ :param order: if provided ('EIA' codes) use insted of the current order
781
+ :return: None or the first found file full path
782
+ """
783
+ for path in self.find_file_iter(name, order=order):
784
+ return path
785
+
786
+ @property
787
+ def first(self) -> Path | None:
788
+ """First DEFINED (not cheking existence) location by the currently set order"""
789
+ for folder in self.defined():
790
+ return folder
791
+
792
+ def first_of(self, order=None) -> Path | None:
793
+ """First DEFINED (not checking existence) by the given or current order
794
+
795
+ :param order: override the current order using 'EIA' codes.
796
+ """
797
+ for folder in self._locations_sequence(order='I'):
798
+ return folder
799
+
800
+ def reset_folders(self):
801
+ self._log.warning(f'Attention! Erasing {self} internal folders.')
802
+ self.folders = []
803
+
804
+
805
+ def glob_folders(folders, file_pattern) -> Iterable[str]:
806
+ """
807
+ Find files using `glob` in MULTIPLE folders.
808
+ With a single folder works as iterable version of `glob`
809
+ :param folders: a folder or iterable of folders
810
+ :param file_pattern: glob pattern for path under the folder
811
+ :return: Iterator over the files found
812
+ """
813
+ from . import as_list
814
+ from more_itertools import collapse
815
+
816
+ return list(collapse(
817
+ glob(str(Path(folder, file_pattern).expanduser()))
818
+ for folder in as_list(folders)))
819
+
820
+
821
+ class PyModuleLocator:
822
+ """
823
+ Allows to locate modules in specific folder and determine their package name.
824
+ """
825
+ _Path = Path | str
826
+
827
+ def __init__(self, folder: _Path):
828
+ """
829
+ Create module locator
830
+ :param folder:
831
+ :param paths:
832
+ :param sys_paths:
833
+ """
834
+ import sys
835
+
836
+ self._cache = {}
837
+ self.package = None
838
+ self.folder = Path(folder).resolve()
839
+
840
+ if self.folder.is_file():
841
+ raise NotADirectoryError(f"Found file insted {str(self.folder)}")
842
+
843
+ # find the longest package root to include the folder
844
+ self.sys_path = max((os.path.commonpath([self.folder, p])
845
+ for p in sys.path if self.folder.is_relative_to(p)
846
+ ), key=len, default=None)
847
+ sep = '\n\t'
848
+ paths_msg = f"max common {self.sys_path} from import paths:{sep}{sep.join(sys.path)}"
849
+ if self.sys_path is None:
850
+ raise ModuleNotFoundError(f"Folder {str(self.folder)} is not in under {paths_msg}")
851
+ elif (_log := logging.getLogger('env')).isEnabledFor(logging.DEBUG):
852
+ _log.debug(f"Found under {paths_msg}")
853
+
854
+ package = '.'.join(self.folder.relative_to(self.sys_path).parts)
855
+ if self._is_valid_route(self.sys_path, package, sys_path=True):
856
+ self.package = package
857
+
858
+ def __repr__(self):
859
+ return f"{self.__class__.__name__}({str(self.folder)})[{self.package}]"
860
+
861
+ @classmethod
862
+ def add_sys_path(cls, *paths, check_exists=True):
863
+ """Normalize and and add given path to sys.path if not already there.
864
+ :param paths: str or Path to add
865
+ :param check_exists: vefify that it exists
866
+ """
867
+ for path in paths:
868
+ path = normalize(path)
869
+ if check_exists and not path.is_dir():
870
+ raise NotADirectoryError(f"Can't add to sys.path not existring dir {path}")
871
+
872
+ if not (path := str(path)) in set(sys.path):
873
+ sys.path.append(path)
874
+
875
+ @staticmethod
876
+ def _is_valid_route(root, route: str, sys_path) -> bool:
877
+ """Validate that given route describes valid package or module located under the `root` folder.
878
+
879
+ That is a folder exists on every dot level with '__init__.py', unless:
880
+ - `route` == '' or points or module directly under the `root`.
881
+
882
+ :param root: parent folder relative to which the `route` is defined
883
+ :param route: dotted package or module name (under the root)
884
+ :param sys_path: True if root is in sys.path and does not need '__init__.py'
885
+
886
+ :return True if valid
887
+ """
888
+ if route == '':
889
+ return True
890
+
891
+ root = Path(root)
892
+ path = root.joinpath(*route.split('.')) # for sure at least 1 part in the split
893
+
894
+ if path.with_suffix('.py').is_file(): # route points to a module
895
+ path = path.parent # set to the folder of this module
896
+
897
+ while path != root: # up from the deepest path check for '__init__.py'
898
+ if not path.joinpath('__init__.py').is_file():
899
+ return False
900
+ path = path.parent
901
+
902
+ if sys_path:
903
+ return True
904
+ return root.joinpath('__init__.py').is_file()
905
+
906
+ def module_import_params(self, module, abs: bool | None = False) -> tuple[str, str | None]:
907
+ """Return a tuple of ``import_module(module, package)`` args given
908
+ an absolute or relative (to `self.package`) module name.
909
+
910
+ Depending on the value of the `abs` argument:
911
+ - ``True`` - module must be absolute
912
+ - ``False`` - must not be absolute
913
+ - ``None`` - no special reuests
914
+
915
+ Given `module` is validated by locating the corresponding file.
916
+
917
+ Failure to satisfy any validation raises ``ModuleNotFoundError``.
918
+
919
+ Return (`module`, `package`)
920
+ """
921
+ if module.startswith('.'):
922
+ if abs is True:
923
+ raise ModuleNotFoundError(f"Relative {module=} provided instead of absolute")
924
+ if self.package is None:
925
+ raise ModuleNotFoundError(f"Relative {module=} can't be located without package")
926
+
927
+ if self._is_valid_route(self.folder, module, sys_path=not self.package):
928
+ return module, self.package # if self.package == '' self.folder is in sys.path
929
+
930
+ raise ModuleNotFoundError(f"No {module=} in the package {self.package}")
931
+ # continue with absolute module
932
+ if abs is False:
933
+ ModuleNotFoundError(f"Expected relative {module=}")
934
+ if not self.is_valid_absolute(module):
935
+ raise ModuleNotFoundError(f"No sys.path to the absolute {module=}")
936
+
937
+ return module, None
938
+
939
+ @classmethod
940
+ def is_valid_absolute(cls, route) -> bool:
941
+ """Validate given absoulte route can be imported baseed on the current sys.path"""
942
+ import sys
943
+ for p in sys.path:
944
+ if cls._is_valid_route(p, route, sys_path=True):
945
+ return True
946
+ return False
947
+
948
+ def file_to_module(self, file: Path, relative=False):
949
+ """Convert file path into its dotted module name.
950
+
951
+ **Example**
952
+
953
+ ``PyModuleLocator`` instance wth:
954
+ ::
955
+ self.folder: /some/path/package/folder
956
+ self.package_root: /some/path/package
957
+
958
+ file: /some/path/package/folder/sub/module.py
959
+ return:
960
+ if relative: sub.module
961
+ else: package.folder.sub.module
962
+
963
+ :param file: path to the file
964
+ :param relative: not full name in the package, relative to the ``self.folder`` location
965
+ """
966
+ if self.package is None:
967
+ raise ModuleNotFoundError(f"{str(self.folder)} does not belong to any package")
968
+
969
+ section = Path(file).relative_to(self.folder)
970
+ pfx = '' if relative else self.package
971
+ return '.'.join([pfx, *section.parent.parts, section.stem]) #
972
+
973
+ @staticmethod
974
+ def modules_under(folder: Path, deep: int = 0, sys_path: bool = False,
975
+ include: str = None, exclude: str = '[._].*') -> Generator[Path]:
976
+ """Generate Path of module files found under specified folder.
977
+
978
+ :param folder: Path to search under
979
+ :param deep: number of levels deep to go, 0 - only the `folder`.
980
+ :param sys_path: if folder is in sys_path: '__init__.py' is not required there
981
+ :param include: re pattern for file names to include
982
+ :param exclude: re pattern for file names to exclude (default, starting with '_'
983
+ """
984
+ included_name = lambda p: (
985
+ (not include or re.fullmatch(include, p.name)) and
986
+ (not exclude or not re.fullmatch(exclude, p.name))
987
+ )
988
+
989
+ if not included_name(folder) or ( # excluded folder
990
+ (deep or not sys_path) # check one every depth (except 0 if sys_path)
991
+ and not folder.joinpath('__init__.py').is_file()
992
+ ): return
993
+
994
+ for file in folder.glob('*.py'): # by files on specific depth
995
+ if included_name(file):
996
+ yield file
997
+
998
+ if deep:
999
+ for sub in folder.glob('*/'):
1000
+ yield from PyModuleLocator.modules_under(sub, sys_path=False, deep=deep - 1,
1001
+ include=include, exclude=exclude)
1002
+
1003
+
1004
+ def _probe_path(path, out_queue):
1005
+ """
1006
+ Return (by putting to the `out_queue`)
1007
+ - ``True`` if path exists
1008
+ - ``False`` if not
1009
+ - ``None`` if check fails
1010
+
1011
+ :param path:
1012
+ :param out_queue:
1013
+ :return:
1014
+ """
1015
+ try:
1016
+ out_queue.put(os.path.exists(normalize(path)))
1017
+ except Exception:
1018
+ out_queue.put(None)
1019
+
1020
+
1021
+ def check_path(path: Path | str, *, timeout=1.0, log: Callable[[str],] = None) -> bool | None:
1022
+ """
1023
+ Safely check if the given `path` exists (``True``|``False``)
1024
+ or there are mounting or access problems (``None``)
1025
+
1026
+ :param path:
1027
+ :param timeout: path is considered not responding if delay > timeout
1028
+ :param log: optional log function (reports only if delay > 0.1)
1029
+
1030
+ :return: ``True|False|None``
1031
+ """
1032
+ with Timer(f"Safe-checked '{path}' in", min=0.1, active=bool(log), out_func=log):
1033
+ import queue, multiprocessing
1034
+
1035
+ q = multiprocessing.Queue(1)
1036
+ p = multiprocessing.Process(target=_probe_path, args=(path, q))
1037
+ p.start()
1038
+ p.join(timeout)
1039
+ if p.is_alive():
1040
+ p.terminate()
1041
+ p.join()
1042
+ return None
1043
+ try:
1044
+ return q.get(False)
1045
+ except queue.Empty:
1046
+ return None # no result sent by the child → treat as failure