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