envstack 1.0.3__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.
- envstack/__init__.py +40 -0
- envstack/cli.py +478 -0
- envstack/config.py +108 -0
- envstack/encrypt.py +361 -0
- envstack/env.py +997 -0
- envstack/envshell.py +143 -0
- envstack/exceptions.py +82 -0
- envstack/logger.py +61 -0
- envstack/node.py +410 -0
- envstack/path.py +448 -0
- envstack/util.py +800 -0
- envstack-1.0.3.dist-info/LICENSE +12 -0
- envstack-1.0.3.dist-info/METADATA +177 -0
- envstack-1.0.3.dist-info/RECORD +17 -0
- envstack-1.0.3.dist-info/WHEEL +5 -0
- envstack-1.0.3.dist-info/entry_points.txt +3 -0
- envstack-1.0.3.dist-info/top_level.txt +1 -0
envstack/path.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
# Redistribution and use in source and binary forms, with or without
|
|
6
|
+
# modification, are permitted provided that the following conditions are met:
|
|
7
|
+
#
|
|
8
|
+
# - Redistributions of source code must retain the above copyright notice,
|
|
9
|
+
# this list of conditions and the following disclaimer.
|
|
10
|
+
#
|
|
11
|
+
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
# and/or other materials provided with the distribution.
|
|
14
|
+
#
|
|
15
|
+
# - Neither the name of the software nor the names of its contributors
|
|
16
|
+
# may be used to endorse or promote products derived from this software
|
|
17
|
+
# without specific prior written permission.
|
|
18
|
+
#
|
|
19
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
22
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
23
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
24
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
25
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
26
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
27
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
28
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
29
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
__doc__ = """
|
|
33
|
+
Contains template path classes and functions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
from typing import Iterable, Optional, Tuple
|
|
39
|
+
|
|
40
|
+
from envstack import config, logger
|
|
41
|
+
from envstack.exceptions import * # noqa
|
|
42
|
+
|
|
43
|
+
# env var regex: matches $VAR or ${VAR}
|
|
44
|
+
env_var_re = re.compile(r"\$\{[^}]+\}|\$\w+")
|
|
45
|
+
|
|
46
|
+
# template path field regex: extracts bracketed {keys}
|
|
47
|
+
keyword_re = re.compile(r"{(\w*)(?::\d*d)?(?::\d*\.\d*f)?}")
|
|
48
|
+
|
|
49
|
+
# template path formatting regex
|
|
50
|
+
formats_re = re.compile(r"{([\w\:\.\d]*)}")
|
|
51
|
+
|
|
52
|
+
# template path field backref regex
|
|
53
|
+
field_re = re.compile(r"\(\?P\<(.*?)\>\[\^,;\\\/]\*\)")
|
|
54
|
+
|
|
55
|
+
# template path directory delimiter regex
|
|
56
|
+
directory_re = re.compile(r"[^\\/]+|[\\/]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _escape_env_vars(s: str) -> str:
|
|
60
|
+
"""Convert ${VAR} -> ${{VAR}} so str.format ignores it, while leaving {token}
|
|
61
|
+
intact."""
|
|
62
|
+
|
|
63
|
+
def repl(m: re.Match) -> str:
|
|
64
|
+
tok = m.group(0)
|
|
65
|
+
if tok.startswith("${"):
|
|
66
|
+
inner = tok[2:-1]
|
|
67
|
+
return "${{" + inner + "}}"
|
|
68
|
+
return tok
|
|
69
|
+
|
|
70
|
+
return env_var_re.sub(repl, s)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _numdirs(p: str) -> int:
|
|
74
|
+
"""Returns the number of directory levels in a path string, used for template"""
|
|
75
|
+
return str(p).replace("\\", "/").count("/")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_resolved_stack(
|
|
79
|
+
stack: str,
|
|
80
|
+
*,
|
|
81
|
+
platform: str = config.PLATFORM,
|
|
82
|
+
scope: Optional[str] = None,
|
|
83
|
+
):
|
|
84
|
+
"""Load + resolve an envstack environment stack. Intentionally uses envstack's
|
|
85
|
+
own resolution model rather than os.environ as the primary source of truth."""
|
|
86
|
+
from .env import load_environ, resolve_environ
|
|
87
|
+
|
|
88
|
+
raw = load_environ(stack, platform=platform, scope=scope)
|
|
89
|
+
return resolve_environ(raw)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _expand_env_vars(template: str, env: dict) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Expand $VARS / ${VARS} in `template` using the provided `env` mapping.
|
|
95
|
+
Uses EnvVar (string.Template-based).
|
|
96
|
+
"""
|
|
97
|
+
from .env import EnvVar
|
|
98
|
+
|
|
99
|
+
# EnvVar.expand() returns either EnvVar, list, or dict depending on input
|
|
100
|
+
expanded = EnvVar(template).expand(env, recursive=True)
|
|
101
|
+
|
|
102
|
+
if isinstance(expanded, list) or isinstance(expanded, dict):
|
|
103
|
+
raise InvalidSyntax(
|
|
104
|
+
f"Path template expansion must resolve to a string, got {type(expanded)}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# expanded may already be a string-like EnvVar
|
|
108
|
+
return str(expanded)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _iter_template_items(env: dict) -> Iterable[Tuple[str, str]]:
|
|
112
|
+
"""
|
|
113
|
+
Heuristic filter for "likely path templates" inside an environment.
|
|
114
|
+
|
|
115
|
+
We avoid assuming a special namespace and instead scan the stack for values
|
|
116
|
+
that look like templates.
|
|
117
|
+
"""
|
|
118
|
+
for k, v in env.items():
|
|
119
|
+
if not isinstance(v, str) or not v:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# must contain at least one format field; otherwise it's not a template
|
|
123
|
+
if "{" not in v or "}" not in v:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# most path templates contain a separator; kept loose
|
|
127
|
+
if "/" not in v and "\\" not in v:
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
yield k, v
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Path(str):
|
|
134
|
+
"""Subclass of `str` with some platform agnostic pathing support."""
|
|
135
|
+
|
|
136
|
+
SEPARATORS = ["/", "\\"]
|
|
137
|
+
|
|
138
|
+
def __init__(self, path, platform: str = config.PLATFORM):
|
|
139
|
+
self.path = path
|
|
140
|
+
self.platform = platform
|
|
141
|
+
|
|
142
|
+
def __new__(cls, path, platform: str = config.PLATFORM):
|
|
143
|
+
obj = super().__new__(cls, path)
|
|
144
|
+
obj.platform = platform
|
|
145
|
+
return obj
|
|
146
|
+
|
|
147
|
+
def __repr__(self):
|
|
148
|
+
return "<{0} '{1}'>".format(self.__class__.__name__, self.path)
|
|
149
|
+
|
|
150
|
+
def __str__(self):
|
|
151
|
+
return str(self.path)
|
|
152
|
+
|
|
153
|
+
def basename(self):
|
|
154
|
+
"""Returns the final component of the path."""
|
|
155
|
+
return os.path.basename(str(self))
|
|
156
|
+
|
|
157
|
+
def dirname(self):
|
|
158
|
+
"""Returns the directory component of the path."""
|
|
159
|
+
return os.path.dirname(str(self))
|
|
160
|
+
|
|
161
|
+
def levels(self):
|
|
162
|
+
"""Returns number of directory levels in the path."""
|
|
163
|
+
tokens = directory_re.findall(self.path)
|
|
164
|
+
return [t for t in tokens if t not in self.SEPARATORS]
|
|
165
|
+
|
|
166
|
+
def to_platform(
|
|
167
|
+
self,
|
|
168
|
+
platform: str = config.PLATFORM,
|
|
169
|
+
*,
|
|
170
|
+
stack: str = config.DEFAULT_NAMESPACE,
|
|
171
|
+
scope: Optional[str] = None,
|
|
172
|
+
root_var: str = "ROOT",
|
|
173
|
+
):
|
|
174
|
+
"""
|
|
175
|
+
Converts path root from this Path.platform to `platform` using ROOT values
|
|
176
|
+
from the resolved envstack environment for each platform.
|
|
177
|
+
|
|
178
|
+
:param platform: target platform name
|
|
179
|
+
:param stack: envstack stack to load for ROOT values (e.g. 'fps')
|
|
180
|
+
:param scope: scope to resolve stack from (defaults to dirname of this path)
|
|
181
|
+
:param root_var: env var name to treat as platform root (default: ROOT)
|
|
182
|
+
:returns: converted path string
|
|
183
|
+
"""
|
|
184
|
+
if platform == self.platform:
|
|
185
|
+
return str(self)
|
|
186
|
+
|
|
187
|
+
scope = scope or self.scope()
|
|
188
|
+
try:
|
|
189
|
+
from_env = _load_resolved_stack(stack, platform=self.platform, scope=scope)
|
|
190
|
+
to_env = _load_resolved_stack(stack, platform=platform, scope=scope)
|
|
191
|
+
except Exception as err:
|
|
192
|
+
raise InvalidPath(
|
|
193
|
+
f"Failed to load stack '{stack}' for platform conversion: {err}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
from_root = from_env.get(root_var)
|
|
197
|
+
to_root = to_env.get(root_var)
|
|
198
|
+
|
|
199
|
+
if not from_root or not to_root:
|
|
200
|
+
raise TemplateNotFound(
|
|
201
|
+
f"{root_var} undefined for platform conversion ({self.platform} -> {platform}) "
|
|
202
|
+
f"in stack '{stack}'"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# use regex escape in case roots contain special chars
|
|
206
|
+
return re.sub(r"^{}".format(re.escape(from_root)), to_root, self.path)
|
|
207
|
+
|
|
208
|
+
def to_str(self):
|
|
209
|
+
"""Returns this path as a string."""
|
|
210
|
+
return str(self)
|
|
211
|
+
|
|
212
|
+
def scope(self):
|
|
213
|
+
"""Returns the scope for this path."""
|
|
214
|
+
return os.path.dirname(str(self))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Template(object):
|
|
218
|
+
"""Path Template class."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, path: str):
|
|
221
|
+
assert path, "Template path format cannot be empty"
|
|
222
|
+
self.path_format = str(path)
|
|
223
|
+
|
|
224
|
+
def __repr__(self):
|
|
225
|
+
return f"<Template '{self.path_format}'>"
|
|
226
|
+
|
|
227
|
+
def __str__(self):
|
|
228
|
+
return self.path_format
|
|
229
|
+
|
|
230
|
+
def _parse(self, pattern: str):
|
|
231
|
+
tokens = pattern.split(self.path_format)
|
|
232
|
+
keys = []
|
|
233
|
+
for token in tokens[1::2]:
|
|
234
|
+
if token not in keys:
|
|
235
|
+
keys.append(token)
|
|
236
|
+
return keys
|
|
237
|
+
|
|
238
|
+
def apply_fields(self, **fields):
|
|
239
|
+
"""
|
|
240
|
+
Applies key/value pairs matching template format.
|
|
241
|
+
|
|
242
|
+
:param fields: key/values to apply to template.
|
|
243
|
+
:returns: resolved path as Path.
|
|
244
|
+
:raises: MissingFieldError.
|
|
245
|
+
"""
|
|
246
|
+
formats = self.get_formats()
|
|
247
|
+
|
|
248
|
+
def cast(k, v):
|
|
249
|
+
fmt = formats.get(k, str)
|
|
250
|
+
try:
|
|
251
|
+
return fmt(v)
|
|
252
|
+
except ValueError:
|
|
253
|
+
raise Exception("{0} must be {1}".format(k, fmt.__name__))
|
|
254
|
+
|
|
255
|
+
formatted = dict((k, cast(k, v)) for k, v in fields.items())
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
return Path(self.path_format.format(**formatted))
|
|
259
|
+
except KeyError as err:
|
|
260
|
+
raise MissingFieldError(err) # noqa
|
|
261
|
+
|
|
262
|
+
def get_keywords(self):
|
|
263
|
+
"""Returns a list of required keywords."""
|
|
264
|
+
return self._parse(keyword_re)
|
|
265
|
+
|
|
266
|
+
def get_formats(self):
|
|
267
|
+
"""Returns a map of keywords to value classes."""
|
|
268
|
+
matches = self._parse(formats_re)
|
|
269
|
+
results = {}
|
|
270
|
+
for key in matches:
|
|
271
|
+
_type = str
|
|
272
|
+
if ":" in key:
|
|
273
|
+
key, f = key.split(":")
|
|
274
|
+
if "d" in f:
|
|
275
|
+
_type = int
|
|
276
|
+
elif "f" in f:
|
|
277
|
+
_type = float
|
|
278
|
+
results[key] = _type
|
|
279
|
+
return results
|
|
280
|
+
|
|
281
|
+
def get_fields(self, path: str):
|
|
282
|
+
"""Gets key/value pairs from path that map to template path."""
|
|
283
|
+
path = path.replace("\\", "/")
|
|
284
|
+
path_format = self.path_format.replace("\\", "/")
|
|
285
|
+
|
|
286
|
+
tokens = keyword_re.split(path_format)
|
|
287
|
+
keywords = tokens[1::2]
|
|
288
|
+
|
|
289
|
+
tokens[1::2] = map(r"(?P<{}>[^,;\/]*)".format, keywords)
|
|
290
|
+
tokens[0::2] = map(re.escape, tokens[0::2])
|
|
291
|
+
|
|
292
|
+
# look for back references
|
|
293
|
+
for i in range(len(tokens)):
|
|
294
|
+
fm = field_re.match(tokens[i])
|
|
295
|
+
if fm is not None:
|
|
296
|
+
name = fm.group(1)
|
|
297
|
+
back_ref = "(?P={name})".format(name=name)
|
|
298
|
+
try:
|
|
299
|
+
while True:
|
|
300
|
+
index = tokens[i + 1 :].index(tokens[i]) # noqa
|
|
301
|
+
tokens[i + 1 + index] = back_ref
|
|
302
|
+
except ValueError:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
pattern = "".join(tokens)
|
|
306
|
+
matches = re.match(pattern, path)
|
|
307
|
+
if not matches:
|
|
308
|
+
raise InvalidPath(path) # noqa
|
|
309
|
+
|
|
310
|
+
formats = self.get_formats()
|
|
311
|
+
|
|
312
|
+
def cast(k, v):
|
|
313
|
+
return formats.get(k, str)(v)
|
|
314
|
+
|
|
315
|
+
return {k: cast(k, matches.group(k)) for k in keywords}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def extract_fields(
|
|
319
|
+
filepath: str,
|
|
320
|
+
template: Template,
|
|
321
|
+
*,
|
|
322
|
+
stack: str = config.DEFAULT_NAMESPACE,
|
|
323
|
+
platform: str = config.PLATFORM,
|
|
324
|
+
):
|
|
325
|
+
"""
|
|
326
|
+
Convenience function that extracts template fields from a given filepath for
|
|
327
|
+
a given template instance or template name.
|
|
328
|
+
|
|
329
|
+
:param filepath: path to file
|
|
330
|
+
:param template: Template instance or name of template in `stack`
|
|
331
|
+
:param stack: stack namespace to load templates from (default: config.DEFAULT_NAMESPACE)
|
|
332
|
+
:param platform: optional platform name
|
|
333
|
+
:returns: dictionary of template fields
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
p = Path(filepath, platform=platform)
|
|
337
|
+
if isinstance(template, str):
|
|
338
|
+
template = get_template(
|
|
339
|
+
template, stack=stack, platform=platform, scope=p.scope()
|
|
340
|
+
)
|
|
341
|
+
return template.get_fields(filepath)
|
|
342
|
+
|
|
343
|
+
except InvalidPath: # noqa
|
|
344
|
+
logger.log.debug(
|
|
345
|
+
"path does not match template: {0} {1}".format(template, filepath)
|
|
346
|
+
)
|
|
347
|
+
return {}
|
|
348
|
+
|
|
349
|
+
except Exception as err:
|
|
350
|
+
logger.log.debug("error extracting fields: {}".format(err))
|
|
351
|
+
return {}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_scope(filepath: str):
|
|
355
|
+
"""Convenience function that returns the scope of a given filepath."""
|
|
356
|
+
return Path(filepath).scope()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_template(
|
|
360
|
+
name: str,
|
|
361
|
+
*,
|
|
362
|
+
stack: str = config.DEFAULT_NAMESPACE,
|
|
363
|
+
platform: str = config.PLATFORM,
|
|
364
|
+
scope: str = None,
|
|
365
|
+
expand: bool = True,
|
|
366
|
+
):
|
|
367
|
+
"""
|
|
368
|
+
Returns a Template instance for a given template name located in `stack`.
|
|
369
|
+
|
|
370
|
+
- Loads + resolves the envstack environment for `stack`
|
|
371
|
+
- Fetches template string from resolved env
|
|
372
|
+
- Expands $VARS / ${VARS} inside the template string using the resolved env
|
|
373
|
+
- Returns Template(expanded_string)
|
|
374
|
+
|
|
375
|
+
:param name: template variable name
|
|
376
|
+
:param stack: envstack stack to load (e.g. 'fps')
|
|
377
|
+
:param platform: platform name
|
|
378
|
+
:param scope: scope (default: cwd via load_environ)
|
|
379
|
+
:param expand: whether to expand $VARS in the template string
|
|
380
|
+
"""
|
|
381
|
+
env = _load_resolved_stack(stack, platform=platform, scope=scope)
|
|
382
|
+
|
|
383
|
+
template = env.get(name)
|
|
384
|
+
if not template:
|
|
385
|
+
raise TemplateNotFound(name) # noqa
|
|
386
|
+
|
|
387
|
+
if expand:
|
|
388
|
+
template = _expand_env_vars(template, env)
|
|
389
|
+
else:
|
|
390
|
+
template = _escape_env_vars(template)
|
|
391
|
+
|
|
392
|
+
return Template(template)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def match_template(
|
|
396
|
+
path: str,
|
|
397
|
+
*,
|
|
398
|
+
stack: str = config.DEFAULT_NAMESPACE,
|
|
399
|
+
platform: str = config.PLATFORM,
|
|
400
|
+
scope: str = None,
|
|
401
|
+
expand: bool = True,
|
|
402
|
+
):
|
|
403
|
+
"""
|
|
404
|
+
Returns a Template that matches a given `path`.
|
|
405
|
+
|
|
406
|
+
- Loads + resolves `stack`
|
|
407
|
+
- Considers values that look like path templates
|
|
408
|
+
- Orders templates by directory depth (more specific first)
|
|
409
|
+
- Returns first matching template, or raises ValueError if the path matches
|
|
410
|
+
multiple templates of the same depth.
|
|
411
|
+
|
|
412
|
+
:param path: path to match against templates
|
|
413
|
+
:param stack: envstack stack to load (e.g. 'fps')
|
|
414
|
+
:param platform: platform name
|
|
415
|
+
:param scope: scope (default: cwd via load_environ)
|
|
416
|
+
:param expand: whether to expand $VARS in the template strings before matching
|
|
417
|
+
:raises: ValueError if `path` matches multiple templates at the same depth
|
|
418
|
+
:returns: matching Template or None
|
|
419
|
+
"""
|
|
420
|
+
env = _load_resolved_stack(stack, platform=platform, scope=scope)
|
|
421
|
+
|
|
422
|
+
items = list(_iter_template_items(env))
|
|
423
|
+
items.sort(key=lambda kv: _numdirs(kv[1]), reverse=True)
|
|
424
|
+
|
|
425
|
+
matched = None
|
|
426
|
+
matched_depth = None
|
|
427
|
+
|
|
428
|
+
for name, path_format in items:
|
|
429
|
+
try:
|
|
430
|
+
if expand:
|
|
431
|
+
path_format_expanded = _expand_env_vars(path_format, env)
|
|
432
|
+
else:
|
|
433
|
+
path_format_expanded = path_format
|
|
434
|
+
|
|
435
|
+
template_test = Template(path_format_expanded)
|
|
436
|
+
|
|
437
|
+
if template_test.get_fields(path):
|
|
438
|
+
depth = _numdirs(template_test.path_format)
|
|
439
|
+
if matched is None:
|
|
440
|
+
matched = template_test
|
|
441
|
+
matched_depth = depth
|
|
442
|
+
elif depth == matched_depth:
|
|
443
|
+
raise ValueError("path matches more than one template")
|
|
444
|
+
|
|
445
|
+
except InvalidPath: # noqa
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
return matched
|