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/util.py
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
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 common utility functions and classes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import functools
|
|
37
|
+
import glob
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
from ast import literal_eval
|
|
43
|
+
from collections import OrderedDict
|
|
44
|
+
|
|
45
|
+
import yaml
|
|
46
|
+
|
|
47
|
+
from envstack import config
|
|
48
|
+
from envstack.node import (
|
|
49
|
+
AESGCMNode,
|
|
50
|
+
Base64Node,
|
|
51
|
+
EncryptedNode,
|
|
52
|
+
FernetNode,
|
|
53
|
+
CustomLoader,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# default memoization cache timeout in seconds
|
|
57
|
+
CACHE_TIMEOUT = 5
|
|
58
|
+
|
|
59
|
+
# value for unresolvable variables
|
|
60
|
+
null = ""
|
|
61
|
+
|
|
62
|
+
# regular expression pattern for matching windows drive letters
|
|
63
|
+
drive_letter_pattern = re.compile(r"(?P<sep>[:;])?(?P<drive>[A-Z]:[/\\])")
|
|
64
|
+
|
|
65
|
+
# regular expression pattern for bash-like variable expansion
|
|
66
|
+
variable_pattern = re.compile(
|
|
67
|
+
r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# regular expression pattern for command substitution
|
|
71
|
+
cmdsub_pattern = re.compile(r"^\s*\$\((?P<cmd>.*)\)\s*$", re.DOTALL)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cache(func):
|
|
75
|
+
"""Function decorator to memoize return data."""
|
|
76
|
+
|
|
77
|
+
cache_dict = {}
|
|
78
|
+
|
|
79
|
+
@functools.wraps(func)
|
|
80
|
+
def wrapper(*args, **kwargs):
|
|
81
|
+
key = (args, tuple(kwargs.items()))
|
|
82
|
+
if key in cache_dict:
|
|
83
|
+
result, timestamp = cache_dict[key]
|
|
84
|
+
if time.time() - timestamp <= CACHE_TIMEOUT:
|
|
85
|
+
return result
|
|
86
|
+
result = func(*args, **kwargs)
|
|
87
|
+
cache_dict[key] = (result, time.time())
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def clear_sys_path(var: str = "PYTHONPATH"):
|
|
94
|
+
"""
|
|
95
|
+
Remove paths from sys.path that are in the given environment variable.
|
|
96
|
+
|
|
97
|
+
:param var: The environment variable to remove paths from.
|
|
98
|
+
"""
|
|
99
|
+
for path in get_paths_from_var(var):
|
|
100
|
+
if path and path in sys.path:
|
|
101
|
+
sys.path.remove(path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def decode_value(value: str):
|
|
105
|
+
"""Returns a decoded value that's been encoded by a wrapper.
|
|
106
|
+
|
|
107
|
+
Decoding encoded environments can be tricky. For example, it must account
|
|
108
|
+
for path templates that include curly braces, e.g. path templates string
|
|
109
|
+
like this must be preserved:
|
|
110
|
+
|
|
111
|
+
'/path/with/{variable}'
|
|
112
|
+
|
|
113
|
+
:param value: wrapper encoded env value.
|
|
114
|
+
:returns: decoded value.
|
|
115
|
+
"""
|
|
116
|
+
# TODO: find a better way to encode/decode wrapper envs
|
|
117
|
+
return (
|
|
118
|
+
str(value)
|
|
119
|
+
.replace("'[", "[")
|
|
120
|
+
.replace("]'", "]")
|
|
121
|
+
.replace('"[', "[")
|
|
122
|
+
.replace(']"', "]")
|
|
123
|
+
.replace('"{"', "{'")
|
|
124
|
+
.replace('"}"', "'}")
|
|
125
|
+
.replace("'{'", "{'")
|
|
126
|
+
.replace("'}'", "'}")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def dedupe_list(lst: list):
|
|
131
|
+
"""
|
|
132
|
+
Deduplicates a list while preserving the original order. Useful for
|
|
133
|
+
deduplicating paths.
|
|
134
|
+
|
|
135
|
+
:param lst: The list to deduplicate.
|
|
136
|
+
:returns: The deduplicated list.
|
|
137
|
+
"""
|
|
138
|
+
return list(OrderedDict.fromkeys(lst))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def split_posix_paths(path_str: str):
|
|
142
|
+
"""
|
|
143
|
+
Splits a path string using the posix path separator.
|
|
144
|
+
|
|
145
|
+
:param path_str: The input path string.
|
|
146
|
+
:returns: The split path list.
|
|
147
|
+
"""
|
|
148
|
+
return [p.strip() for p in path_str.split(":") if p.strip()]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def split_windows_paths(path_str: str):
|
|
152
|
+
"""
|
|
153
|
+
Splits a windows-style path string that may contain a mix of colon and
|
|
154
|
+
semicolon delimiters, while preserving drive letter patterns. Drive letters
|
|
155
|
+
must be uppercase.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
Input: "C:\\Program Files\\Python:D:/path2:E:/path3:/usr/local/bin"
|
|
159
|
+
Output: ['C:\\Program Files\\Python', 'D:/path2', 'E:/path3', '/usr/local/bin']
|
|
160
|
+
|
|
161
|
+
:param path_str: The input path string.
|
|
162
|
+
:returns: The split path list.
|
|
163
|
+
"""
|
|
164
|
+
result = []
|
|
165
|
+
tokens = [p.strip() for p in path_str.split(";") if p.strip()]
|
|
166
|
+
|
|
167
|
+
for token in tokens:
|
|
168
|
+
# windows-style path token; insert a marker before drive letters
|
|
169
|
+
if re.match(r"^[A-Z]:[/\\]", token) or "\\" in token:
|
|
170
|
+
modified = drive_letter_pattern.sub(lambda m: "|" + m.group("drive"), token)
|
|
171
|
+
# split on the marker, then on colons that are not in drive-letters
|
|
172
|
+
result += [
|
|
173
|
+
p
|
|
174
|
+
for part in modified.split("|")
|
|
175
|
+
for p in re.split(
|
|
176
|
+
r"(?<![A-Z]):", part
|
|
177
|
+
) # capture colons not in drive letters
|
|
178
|
+
if p
|
|
179
|
+
]
|
|
180
|
+
else:
|
|
181
|
+
result += split_posix_paths(token)
|
|
182
|
+
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def split_paths(path_str: str, platform: str = config.PLATFORM):
|
|
187
|
+
"""
|
|
188
|
+
Splits a path string using the platform-specific path separator.
|
|
189
|
+
|
|
190
|
+
:param path_str: The input path string.
|
|
191
|
+
:param platform: The platform to use.
|
|
192
|
+
:returns: The split path list.
|
|
193
|
+
"""
|
|
194
|
+
if platform == "windows":
|
|
195
|
+
return split_windows_paths(path_str)
|
|
196
|
+
else:
|
|
197
|
+
return split_posix_paths(path_str)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def dedupe_paths(path_str: str, platform: str = config.PLATFORM):
|
|
201
|
+
"""
|
|
202
|
+
Deduplicates paths from a colon-separated string.
|
|
203
|
+
|
|
204
|
+
:param path_str: The input path string.
|
|
205
|
+
:platform: The platform to use.
|
|
206
|
+
:returns: The deduplicated path string.
|
|
207
|
+
"""
|
|
208
|
+
deduped = dedupe_list(split_paths(path_str, platform))
|
|
209
|
+
joiner = ";" if platform == "windows" else ":"
|
|
210
|
+
return joiner.join(deduped)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def detect_path(s: str):
|
|
214
|
+
"""
|
|
215
|
+
Returns True if the given string looks like a filesystem path,
|
|
216
|
+
and False if it appears to be a URL or not a path.
|
|
217
|
+
|
|
218
|
+
This heuristic checks:
|
|
219
|
+
- That the string does not start with a URL scheme
|
|
220
|
+
- Windows drive letters or UNC paths
|
|
221
|
+
- POSIX absolute paths (starting with '/')
|
|
222
|
+
- Relative paths if they contain path separators or a file extension marker.
|
|
223
|
+
|
|
224
|
+
Note: Some valid file names (like "README") may be ambiguous.
|
|
225
|
+
|
|
226
|
+
:param s: The input string.
|
|
227
|
+
:returns: True if the string looks like a filesystem path.
|
|
228
|
+
"""
|
|
229
|
+
s = s.strip()
|
|
230
|
+
if not s:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
# exclude variables
|
|
234
|
+
if s.startswith("$") or s.startswith("{") or s.endswith("}"):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
# exclude URL-like strings (scheme://...)
|
|
238
|
+
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", s):
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
# windows: drive-letter (absolute or relative) or UNC path
|
|
242
|
+
if re.match(r"^[a-zA-Z]:", s) or s.startswith(r"\\\\"):
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
# posix: absolute path starting with "/"
|
|
246
|
+
if s.startswith("/"):
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
# check for delimiter-separated paths
|
|
250
|
+
if ";" in s or (":" in s and s.count(":") > 1):
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
# check if the string contains a directory separator
|
|
254
|
+
if "/" in s or r"\\" in s:
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
# check if the string has a dot (likely indicating a file extension)
|
|
258
|
+
# if re.search(r"(?<!^)\.", s):
|
|
259
|
+
# return True
|
|
260
|
+
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def dict_diff(dict1: dict, dict2: dict):
|
|
265
|
+
"""
|
|
266
|
+
Compare two dictionaries and return their differences.
|
|
267
|
+
|
|
268
|
+
:param dict1: First dictionary.
|
|
269
|
+
:param dict2: Second dictionary.
|
|
270
|
+
:returns: diff dict: 'added', 'removed', 'changed', and 'unchanged'.
|
|
271
|
+
"""
|
|
272
|
+
added = {k: dict2[k] for k in dict2 if k not in dict1}
|
|
273
|
+
removed = {k: dict1[k] for k in dict1 if k not in dict2}
|
|
274
|
+
changed = {
|
|
275
|
+
k: (dict1[k], dict2[k]) for k in dict1 if k in dict2 and dict1[k] != dict2[k]
|
|
276
|
+
}
|
|
277
|
+
unchanged = {k: dict1[k] for k in dict1 if k in dict2 and dict1[k] == dict2[k]}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
"added": added,
|
|
281
|
+
"removed": removed,
|
|
282
|
+
"changed": changed,
|
|
283
|
+
"unchanged": unchanged,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def encode(env: dict):
|
|
288
|
+
"""Returns environment as a dict with str encoded key/values for passing to
|
|
289
|
+
wrapper subprocesses.
|
|
290
|
+
|
|
291
|
+
:param env: `Env` instance or os.environ.
|
|
292
|
+
:param resolved: fully resolve values (default=True).
|
|
293
|
+
:returns: dict with bytestring key/values.
|
|
294
|
+
"""
|
|
295
|
+
c = lambda v: str(v) # noqa: E731
|
|
296
|
+
return dict((c(k), c(v)) for k, v in env.items())
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_paths_from_var(
|
|
300
|
+
var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True
|
|
301
|
+
):
|
|
302
|
+
"""Returns a list of paths from a given pathsep separated environment
|
|
303
|
+
variable.
|
|
304
|
+
|
|
305
|
+
:param var: The environment variable to get paths from.
|
|
306
|
+
:param pathsep: The path separator to use.
|
|
307
|
+
:param reverse: Reverse the order of the paths.
|
|
308
|
+
:returns: A list of paths.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
paths = []
|
|
312
|
+
value = os.environ.get(var)
|
|
313
|
+
|
|
314
|
+
if value:
|
|
315
|
+
paths = value.split(pathsep)
|
|
316
|
+
|
|
317
|
+
if reverse:
|
|
318
|
+
paths.reverse()
|
|
319
|
+
|
|
320
|
+
return paths
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def get_stack_name(name: str = config.DEFAULT_NAMESPACE):
|
|
324
|
+
"""
|
|
325
|
+
Returns the stack name as a string. The stack name is always the last
|
|
326
|
+
element in the stack list or the basename of the envstack file.
|
|
327
|
+
|
|
328
|
+
:param name: The input name, can be a string, tuple, or list.
|
|
329
|
+
:return: The stack name as a string.
|
|
330
|
+
"""
|
|
331
|
+
if isinstance(name, (tuple, list)):
|
|
332
|
+
name = str(name[-1]) if name else config.DEFAULT_NAMESPACE
|
|
333
|
+
if isinstance(name, str):
|
|
334
|
+
return os.path.splitext(os.path.basename(name))[0]
|
|
335
|
+
else:
|
|
336
|
+
raise ValueError("Invalid input type. Expected string, tuple, or list.")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def evaluate_modifiers(
|
|
340
|
+
expression: str,
|
|
341
|
+
environ: dict = os.environ,
|
|
342
|
+
parent: dict = {},
|
|
343
|
+
resolving: set = None,
|
|
344
|
+
):
|
|
345
|
+
"""
|
|
346
|
+
Evaluates Bash-like variable expansion modifiers.
|
|
347
|
+
|
|
348
|
+
- ${VAR} → empty string if unset
|
|
349
|
+
- ${VAR:=default} → if unset/null, set to default and use it
|
|
350
|
+
- ${VAR:-default} → if unset/null, use default (do not set)
|
|
351
|
+
- ${VAR:?message} → error if unset/null
|
|
352
|
+
|
|
353
|
+
:param expression: The string to evaluate.
|
|
354
|
+
:param environ: The environment variables to use for substitution.
|
|
355
|
+
:param parent: Parent environment variables to use for substitution.
|
|
356
|
+
:param resolving: A set of currently resolving variables to prevent cycles.
|
|
357
|
+
:returns: The evaluated string with variables substituted.
|
|
358
|
+
:raises ValueError: If a variable is not set and a message is provided.
|
|
359
|
+
"""
|
|
360
|
+
if resolving is None:
|
|
361
|
+
resolving = set()
|
|
362
|
+
|
|
363
|
+
def sanitize_value(value):
|
|
364
|
+
if (
|
|
365
|
+
isinstance(value, str)
|
|
366
|
+
and value.endswith("}")
|
|
367
|
+
and not value.startswith("${")
|
|
368
|
+
):
|
|
369
|
+
# trim a dangling '}' when there's no matching '{'
|
|
370
|
+
if value.count("{") == 0 and value.count("}") == 1:
|
|
371
|
+
return value.rstrip("}")
|
|
372
|
+
return value
|
|
373
|
+
elif isinstance(value, str) and detect_path(value):
|
|
374
|
+
return dedupe_paths(value)
|
|
375
|
+
return value
|
|
376
|
+
|
|
377
|
+
def is_template(s: str) -> bool:
|
|
378
|
+
# Check if the string is a template variable like ${VAR}
|
|
379
|
+
return isinstance(s, str) and bool(variable_pattern.search(s))
|
|
380
|
+
|
|
381
|
+
def non_empty(s: str) -> bool:
|
|
382
|
+
# non-empty string, not just whitespace
|
|
383
|
+
return isinstance(s, str) and s != ""
|
|
384
|
+
|
|
385
|
+
def is_literal(s: str) -> bool:
|
|
386
|
+
# non-empty, does not contain ${...}
|
|
387
|
+
return isinstance(s, str) and s != "" and not is_template(s)
|
|
388
|
+
|
|
389
|
+
def substitute_variable(match):
|
|
390
|
+
# extract variable name, operator, and argument from the match
|
|
391
|
+
var_name = match.group(1)
|
|
392
|
+
operator = match.group(2) # '=', '-', '?', or None
|
|
393
|
+
argument = match.group(3) # may be None
|
|
394
|
+
parent_value = parent.get(var_name, "")
|
|
395
|
+
override = os.getenv(var_name, parent_value)
|
|
396
|
+
current = str(
|
|
397
|
+
environ.get(var_name, parent_value or "")
|
|
398
|
+
) # current value we have
|
|
399
|
+
varstr = "${%s}" % var_name
|
|
400
|
+
|
|
401
|
+
# cycle / self-reference guard
|
|
402
|
+
if var_name in resolving:
|
|
403
|
+
if operator in ("=", "-"):
|
|
404
|
+
# if os/parent provides a value, that wins over the default
|
|
405
|
+
if override:
|
|
406
|
+
return str(override)
|
|
407
|
+
default = evaluate_modifiers(argument or "", environ, parent, resolving)
|
|
408
|
+
if operator == "=":
|
|
409
|
+
environ[var_name] = default
|
|
410
|
+
return str(default)
|
|
411
|
+
elif operator == "?":
|
|
412
|
+
msg = argument or f"{var_name} is not set"
|
|
413
|
+
raise ValueError(msg)
|
|
414
|
+
else: # plain ${VAR}
|
|
415
|
+
return str(override or "")
|
|
416
|
+
|
|
417
|
+
resolving.add(var_name)
|
|
418
|
+
try:
|
|
419
|
+
# replace literal self-references inside current (best-effort)
|
|
420
|
+
if current and varstr in current:
|
|
421
|
+
current = current.replace(varstr, override or "")
|
|
422
|
+
|
|
423
|
+
# defaults branch (:=, :-)
|
|
424
|
+
if operator in ("=", "-"):
|
|
425
|
+
# 1) explicit env/parent wins if non-empty (bash: considered "set")
|
|
426
|
+
if non_empty(override):
|
|
427
|
+
return str(override)
|
|
428
|
+
|
|
429
|
+
# 2) compute the effective value of the current entry
|
|
430
|
+
if is_template(current):
|
|
431
|
+
eff = evaluate_modifiers(current, environ, parent, resolving)
|
|
432
|
+
else:
|
|
433
|
+
eff = current or ""
|
|
434
|
+
|
|
435
|
+
# 3) if effective value is non-empty, do NOT take default
|
|
436
|
+
if non_empty(eff):
|
|
437
|
+
return str(eff)
|
|
438
|
+
|
|
439
|
+
# 4) unset or null, use default (and assign for :=)
|
|
440
|
+
default = evaluate_modifiers(argument or "", environ, parent, resolving)
|
|
441
|
+
if operator == "=":
|
|
442
|
+
environ[var_name] = default
|
|
443
|
+
return str(default)
|
|
444
|
+
|
|
445
|
+
# error branch (:?)
|
|
446
|
+
elif operator == "?":
|
|
447
|
+
if not current:
|
|
448
|
+
msg = argument or f"{var_name} is not set"
|
|
449
|
+
raise ValueError(msg)
|
|
450
|
+
# resolve if it contains vars
|
|
451
|
+
return (
|
|
452
|
+
evaluate_modifiers(current, environ, parent, resolving)
|
|
453
|
+
if variable_pattern.search(current)
|
|
454
|
+
else current
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
elif operator is None:
|
|
458
|
+
# check for command substitution
|
|
459
|
+
if config.ALLOW_COMMANDS and cmdsub_pattern.match(str(current)):
|
|
460
|
+
return str(evaluate_command(current))
|
|
461
|
+
if is_literal(current):
|
|
462
|
+
return current # file literal wins
|
|
463
|
+
if is_template(current):
|
|
464
|
+
return str(evaluate_modifiers(current, environ, parent, resolving))
|
|
465
|
+
# current is unset/empty, use parent/os if present, else empty
|
|
466
|
+
return str(override or "")
|
|
467
|
+
|
|
468
|
+
# simple ${VAR}
|
|
469
|
+
else:
|
|
470
|
+
value = current or override or ""
|
|
471
|
+
if variable_pattern.search(value):
|
|
472
|
+
value = evaluate_modifiers(value, environ, parent, resolving)
|
|
473
|
+
return str(value)
|
|
474
|
+
|
|
475
|
+
finally:
|
|
476
|
+
resolving.discard(var_name)
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
if isinstance(expression, (int, float, bool)):
|
|
480
|
+
expression = str(expression)
|
|
481
|
+
|
|
482
|
+
result = variable_pattern.sub(substitute_variable, expression)
|
|
483
|
+
|
|
484
|
+
if variable_pattern.search(result):
|
|
485
|
+
result = evaluate_modifiers(result, environ, parent, resolving)
|
|
486
|
+
|
|
487
|
+
except RecursionError:
|
|
488
|
+
# treat cycles as empty string instead of crashing (like bash)
|
|
489
|
+
result = ""
|
|
490
|
+
|
|
491
|
+
except TypeError:
|
|
492
|
+
# node/list/dict handlers
|
|
493
|
+
if isinstance(expression, AESGCMNode):
|
|
494
|
+
result = expression.resolve(env=environ)
|
|
495
|
+
elif isinstance(expression, Base64Node):
|
|
496
|
+
result = expression.resolve(env=environ)
|
|
497
|
+
elif isinstance(expression, EncryptedNode):
|
|
498
|
+
result = expression.resolve(env=environ)
|
|
499
|
+
elif isinstance(expression, FernetNode):
|
|
500
|
+
result = expression.resolve(env=environ)
|
|
501
|
+
elif isinstance(expression, list):
|
|
502
|
+
result = [evaluate_modifiers(v, environ, parent) for v in expression]
|
|
503
|
+
elif isinstance(expression, dict):
|
|
504
|
+
result = {
|
|
505
|
+
k: evaluate_modifiers(v, environ, parent) for k, v in expression.items()
|
|
506
|
+
}
|
|
507
|
+
else:
|
|
508
|
+
result = expression
|
|
509
|
+
|
|
510
|
+
resolved_value = sanitize_value(result)
|
|
511
|
+
|
|
512
|
+
# command substitution
|
|
513
|
+
if config.ALLOW_COMMANDS and isinstance(resolved_value, str):
|
|
514
|
+
resolved_value = evaluate_command(resolved_value)
|
|
515
|
+
|
|
516
|
+
return resolved_value
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def evaluate_command(command: str):
|
|
520
|
+
"""
|
|
521
|
+
Evaluates command substitution in the given string.
|
|
522
|
+
|
|
523
|
+
PYVERSION: $(python --version)
|
|
524
|
+
|
|
525
|
+
:param command: The command string to evaluate.
|
|
526
|
+
:returns: The command output or original string if no command found.
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
from envstack.wrapper import capture_output
|
|
530
|
+
|
|
531
|
+
match = cmdsub_pattern.match(command)
|
|
532
|
+
if match:
|
|
533
|
+
exit_code, out, err = capture_output(match.group(1))
|
|
534
|
+
if exit_code == 0:
|
|
535
|
+
return out.strip()
|
|
536
|
+
else:
|
|
537
|
+
return err.strip() or null
|
|
538
|
+
|
|
539
|
+
return command
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def load_sys_path(
|
|
543
|
+
var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True
|
|
544
|
+
):
|
|
545
|
+
"""
|
|
546
|
+
Add paths from the given environment variable to sys.path.
|
|
547
|
+
|
|
548
|
+
:param var: The environment variable to add paths from.
|
|
549
|
+
:param pathsep: The path separator to use.
|
|
550
|
+
:param reverse: Reverse the order of the paths.
|
|
551
|
+
"""
|
|
552
|
+
for path in get_paths_from_var(var, pathsep, reverse):
|
|
553
|
+
if path and path not in sys.path:
|
|
554
|
+
sys.path.insert(0, path)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def safe_eval(value: str):
|
|
558
|
+
"""
|
|
559
|
+
Returns template value preserving original class. Useful for preserving
|
|
560
|
+
nested values in wrappers. For example, a value of "1.0" returns 1.0, and a
|
|
561
|
+
value of "['a', 'b']" returns ['a', 'b'].
|
|
562
|
+
|
|
563
|
+
:param value: value to evaluate.
|
|
564
|
+
:returns: evaluated value.
|
|
565
|
+
"""
|
|
566
|
+
try:
|
|
567
|
+
eval_func = literal_eval
|
|
568
|
+
except ImportError:
|
|
569
|
+
# warning: security issue
|
|
570
|
+
eval_func = eval
|
|
571
|
+
|
|
572
|
+
if type(value) is str:
|
|
573
|
+
try:
|
|
574
|
+
return eval_func(value)
|
|
575
|
+
except Exception:
|
|
576
|
+
try:
|
|
577
|
+
return eval_func(decode_value(value))
|
|
578
|
+
except Exception:
|
|
579
|
+
return value
|
|
580
|
+
|
|
581
|
+
return value
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def get_stacks():
|
|
585
|
+
"""
|
|
586
|
+
Returns a list of all stack names found in the environment paths.
|
|
587
|
+
"""
|
|
588
|
+
paths = get_paths_from_var("ENVPATH")
|
|
589
|
+
stacks = set()
|
|
590
|
+
|
|
591
|
+
for path in paths:
|
|
592
|
+
env_files = glob.glob(os.path.join(path, "*.env"))
|
|
593
|
+
for env_file in env_files:
|
|
594
|
+
file_name = os.path.basename(env_file)
|
|
595
|
+
stack_name = os.path.splitext(file_name)[0]
|
|
596
|
+
stacks.add(stack_name)
|
|
597
|
+
|
|
598
|
+
return sorted(list(stacks))
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def findenv(var_name: str):
|
|
602
|
+
"""
|
|
603
|
+
Returns a list of paths where the given environment var is set.
|
|
604
|
+
|
|
605
|
+
:param var_name: The environment variable to search for.
|
|
606
|
+
:returns: A list of paths where the variable is set.
|
|
607
|
+
"""
|
|
608
|
+
from envstack.env import trace_var
|
|
609
|
+
|
|
610
|
+
paths = set()
|
|
611
|
+
|
|
612
|
+
stacks = get_stacks()
|
|
613
|
+
|
|
614
|
+
for stack in stacks:
|
|
615
|
+
path = trace_var(stack, var=var_name)
|
|
616
|
+
if path and os.path.exists(path):
|
|
617
|
+
paths.add(path)
|
|
618
|
+
|
|
619
|
+
return sorted(list(paths))
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def print_error(file_path: str, e: Exception):
|
|
623
|
+
"""
|
|
624
|
+
Prints the problematic line and a few surrounding lines for context.
|
|
625
|
+
|
|
626
|
+
:param file_path: Path to the file.
|
|
627
|
+
:param e: The exception.
|
|
628
|
+
"""
|
|
629
|
+
try:
|
|
630
|
+
with open(file_path, "r") as file:
|
|
631
|
+
lines = file.readlines()
|
|
632
|
+
if hasattr(e, "problem_mark") and e.problem_mark:
|
|
633
|
+
line_num = e.problem_mark.line - 1
|
|
634
|
+
# problematic line and a few surrounding lines for context
|
|
635
|
+
start = max(0, line_num - 1)
|
|
636
|
+
end = min(len(lines), line_num + 2)
|
|
637
|
+
for i in range(start, end):
|
|
638
|
+
prefix = ">> " if i == line_num else " "
|
|
639
|
+
print(f"{prefix}{i + 1}: {lines[i].rstrip()}")
|
|
640
|
+
except Exception as ex:
|
|
641
|
+
print("read error:", ex)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def validate_yaml(file_path: str):
|
|
645
|
+
"""
|
|
646
|
+
Loads a YAML file and prints helpful error hints if invalid.
|
|
647
|
+
|
|
648
|
+
:param file_path: Path to the YAML file to validate.
|
|
649
|
+
"""
|
|
650
|
+
try:
|
|
651
|
+
with open(file_path, "r") as stream:
|
|
652
|
+
data = yaml.load(stream.read(), Loader=CustomLoader)
|
|
653
|
+
if not isinstance(data, dict):
|
|
654
|
+
raise yaml.YAMLError("invalid data structure")
|
|
655
|
+
return data
|
|
656
|
+
except OSError as e:
|
|
657
|
+
print(e)
|
|
658
|
+
except yaml.YAMLError as e:
|
|
659
|
+
if hasattr(e, "problem_mark") and e.problem_mark:
|
|
660
|
+
mark = e.problem_mark
|
|
661
|
+
print(f' File "{file_path}" line {mark.line + 1}, column {mark.column}:')
|
|
662
|
+
print_error(file_path, e)
|
|
663
|
+
if hasattr(e, "problem") and e.problem:
|
|
664
|
+
print(f"SyntaxError: {e.problem}")
|
|
665
|
+
else:
|
|
666
|
+
print(f' File "{file_path}":')
|
|
667
|
+
print(f"SyntaxError: {e}")
|
|
668
|
+
|
|
669
|
+
return {}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def unquote_strings(file_path: str):
|
|
673
|
+
"""
|
|
674
|
+
Unquotes all the single quote strings in a given file.
|
|
675
|
+
|
|
676
|
+
:param file_path: Path to the file.
|
|
677
|
+
"""
|
|
678
|
+
with open(file_path, "r") as file:
|
|
679
|
+
content = file.read()
|
|
680
|
+
|
|
681
|
+
updated_content = re.sub(r"'([^']*)'", r"\1", content)
|
|
682
|
+
|
|
683
|
+
with open(file_path, "w") as file:
|
|
684
|
+
file.write(updated_content)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def dump_yaml(file_path: str, data: dict, unquote: bool = True):
|
|
688
|
+
"""
|
|
689
|
+
Dumps a dictionary to a YAML file with custom formatting:
|
|
690
|
+
|
|
691
|
+
- unquotes single quoted strings
|
|
692
|
+
- partitions platform data
|
|
693
|
+
- adds a shebang line to make the file executable
|
|
694
|
+
- adds an include line if it exists in the data
|
|
695
|
+
|
|
696
|
+
:param file_path: Path to the output YAML file.
|
|
697
|
+
:param data: The dictionary to dump.
|
|
698
|
+
:param unquote: Unquote single quoted strings.
|
|
699
|
+
"""
|
|
700
|
+
from envstack.node import CustomDumper, yaml
|
|
701
|
+
|
|
702
|
+
partitioned_data = partition_platform_data(data)
|
|
703
|
+
|
|
704
|
+
# write the platform partidioned data add shebang
|
|
705
|
+
with open(file_path, "w") as file:
|
|
706
|
+
file.write("#!/usr/bin/env envstack\n")
|
|
707
|
+
if data.get("include"):
|
|
708
|
+
file.write(f"include: {data['include']}\n")
|
|
709
|
+
else:
|
|
710
|
+
file.write("include: []\n")
|
|
711
|
+
if "include" in partitioned_data:
|
|
712
|
+
del partitioned_data["include"]
|
|
713
|
+
yaml.dump(
|
|
714
|
+
partitioned_data,
|
|
715
|
+
file,
|
|
716
|
+
Dumper=CustomDumper,
|
|
717
|
+
sort_keys=True,
|
|
718
|
+
default_flow_style=False,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
if os.path.exists(file_path):
|
|
722
|
+
# unquote the merge keys because yaml doesn't like them quoted
|
|
723
|
+
if unquote:
|
|
724
|
+
unquote_strings(file_path)
|
|
725
|
+
|
|
726
|
+
# make the env stack file executable
|
|
727
|
+
try:
|
|
728
|
+
os.chmod(file_path, 0o755)
|
|
729
|
+
except Exception:
|
|
730
|
+
pass
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def partition_platform_data(data: dict):
|
|
734
|
+
"""
|
|
735
|
+
Given a data dictionary with keys 'all', 'darwin', 'linux', 'windows',
|
|
736
|
+
this function finds which key-value pairs are common across all platforms,
|
|
737
|
+
and which are unique to each platform. Platform-specific values go in their
|
|
738
|
+
respective dicts.
|
|
739
|
+
|
|
740
|
+
:param data: dictionary to partition.
|
|
741
|
+
:returns: platform partitioned dictionary.
|
|
742
|
+
"""
|
|
743
|
+
# ensure "all" key is present
|
|
744
|
+
if "all" not in data:
|
|
745
|
+
data["all"] = dict((k, v) for k, v in data.items() if k != "include")
|
|
746
|
+
|
|
747
|
+
# platforms of interest (darwin, linux, windows)
|
|
748
|
+
platforms = ["darwin", "linux", "windows"]
|
|
749
|
+
|
|
750
|
+
# get the union of keys from all platforms
|
|
751
|
+
all_platform_keys = set()
|
|
752
|
+
for p in platforms:
|
|
753
|
+
all_platform_keys |= data.get(p, {}).keys()
|
|
754
|
+
|
|
755
|
+
# determine which keys are common to all platforms
|
|
756
|
+
common_keys = []
|
|
757
|
+
for key in all_platform_keys:
|
|
758
|
+
if all(key in data[p] for p in platforms):
|
|
759
|
+
# get first value for comparison later
|
|
760
|
+
first_value = data[platforms[0]][key]
|
|
761
|
+
# call it common if all platforms have the same value
|
|
762
|
+
if all(data[p][key] == first_value for p in platforms):
|
|
763
|
+
common_keys.append(key)
|
|
764
|
+
|
|
765
|
+
# build a new all dict for common items
|
|
766
|
+
if common_keys or all_platform_keys or data.get("all"):
|
|
767
|
+
new_all = {}
|
|
768
|
+
else:
|
|
769
|
+
new_all = {"<<": "*all"} # avoids syntax errors when no vars are present
|
|
770
|
+
for k in common_keys:
|
|
771
|
+
if k in data["all"]:
|
|
772
|
+
new_all[k] = data["all"][k]
|
|
773
|
+
else:
|
|
774
|
+
new_all[k] = data[platforms[0]][k]
|
|
775
|
+
|
|
776
|
+
# keep in all anything that is platform-agnostic
|
|
777
|
+
for k, v in data["all"].items():
|
|
778
|
+
if k not in all_platform_keys:
|
|
779
|
+
new_all[k] = v
|
|
780
|
+
|
|
781
|
+
# build dicts for each platform with only platform‑specific keys
|
|
782
|
+
new_platform_dicts = {}
|
|
783
|
+
for p in platforms:
|
|
784
|
+
new_platform_dicts[p] = {"<<": "*all"}
|
|
785
|
+
for k, v in data.get(p, {}).items():
|
|
786
|
+
if k not in common_keys:
|
|
787
|
+
new_platform_dicts[p][k] = v
|
|
788
|
+
|
|
789
|
+
# combine with platform-specific dicts
|
|
790
|
+
new_data = {"all": new_all}
|
|
791
|
+
for p in platforms:
|
|
792
|
+
new_data[p] = new_platform_dicts[p]
|
|
793
|
+
|
|
794
|
+
# ensure include is present
|
|
795
|
+
if data.get("include"):
|
|
796
|
+
new_data["include"] = data["include"]
|
|
797
|
+
else:
|
|
798
|
+
new_data["include"] = []
|
|
799
|
+
|
|
800
|
+
return new_data
|