envstack 1.0.3__tar.gz → 1.0.4__tar.gz
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-1.0.3/lib/envstack.egg-info → envstack-1.0.4}/PKG-INFO +11 -1
- {envstack-1.0.3 → envstack-1.0.4}/README.md +4 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/__init__.py +2 -3
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/cli.py +4 -8
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/encrypt.py +9 -3
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/env.py +3 -12
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/node.py +1 -3
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/path.py +3 -9
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/util.py +9 -33
- envstack-1.0.4/lib/envstack/wrapper.py +333 -0
- {envstack-1.0.3 → envstack-1.0.4/lib/envstack.egg-info}/PKG-INFO +11 -1
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/SOURCES.txt +1 -1
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/requires.txt +7 -0
- {envstack-1.0.3 → envstack-1.0.4}/pyproject.toml +26 -1
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_cmds.py +7 -12
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_encrypt.py +1 -6
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_env.py +10 -42
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_node.py +4 -12
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_path.py +1 -3
- envstack-1.0.4/tests/test_performance.py +102 -0
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_util.py +9 -16
- {envstack-1.0.3 → envstack-1.0.4}/tests/test_wrapper.py +4 -6
- envstack-1.0.3/dist.json +0 -21
- {envstack-1.0.3 → envstack-1.0.4}/LICENSE +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/config.py +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/envshell.py +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/exceptions.py +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/logger.py +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/dependency_links.txt +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/entry_points.txt +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/not-zip-safe +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/top_level.txt +0 -0
- {envstack-1.0.3 → envstack-1.0.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: envstack
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Environment variable composition layer for tools and processes.
|
|
5
5
|
Author-email: Ryan Galloway <ryan@rsgalloway.com>
|
|
6
6
|
License: BSD 3-Clause License
|
|
@@ -29,6 +29,16 @@ License-File: LICENSE
|
|
|
29
29
|
Requires-Dist: PyYAML>=5.1.2
|
|
30
30
|
Requires-Dist: cryptography<47,>=43.0.1; python_version < "3.9"
|
|
31
31
|
Requires-Dist: cryptography>=43.0.1; python_version >= "3.9"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest; extra == "dev"
|
|
34
|
+
Requires-Dist: flake8==7.1.1; extra == "dev"
|
|
35
|
+
Requires-Dist: mccabe==0.7.0; extra == "dev"
|
|
36
|
+
Requires-Dist: isort==5.13.2; extra == "dev"
|
|
37
|
+
Requires-Dist: black==24.8.0; extra == "dev"
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<img src="docs/assets/envstack.png" alt="envstack logo" width="560">
|
|
41
|
+
</p>
|
|
32
42
|
|
|
33
43
|
envstack
|
|
34
44
|
========
|
|
@@ -34,7 +34,6 @@ Stacked environment variable management system.
|
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
36
|
__prog__ = "envstack"
|
|
37
|
-
__version__ = "1.0.
|
|
37
|
+
__version__ = "1.0.4"
|
|
38
38
|
|
|
39
|
-
from envstack.env import clear, init, revert, save # noqa: F401
|
|
40
|
-
from envstack.env import load_environ, resolve_environ # noqa: F401
|
|
39
|
+
from envstack.env import clear, init, load_environ, resolve_environ, revert, save # noqa: F401
|
|
@@ -42,11 +42,11 @@ from typing import List
|
|
|
42
42
|
|
|
43
43
|
from envstack import __version__, config
|
|
44
44
|
from envstack.env import (
|
|
45
|
-
bake_environ,
|
|
46
45
|
Env,
|
|
46
|
+
bake_environ,
|
|
47
47
|
encrypt_environ,
|
|
48
|
-
export_env_to_shell,
|
|
49
48
|
export,
|
|
49
|
+
export_env_to_shell,
|
|
50
50
|
load_environ,
|
|
51
51
|
resolve_environ,
|
|
52
52
|
trace_var,
|
|
@@ -394,9 +394,7 @@ def main():
|
|
|
394
394
|
if args.depth:
|
|
395
395
|
print("error: --depth is not valid with --resolve")
|
|
396
396
|
return 2
|
|
397
|
-
resolved = resolve_environ(
|
|
398
|
-
load_environ(args.namespace, platform=args.platform)
|
|
399
|
-
)
|
|
397
|
+
resolved = resolve_environ(load_environ(args.namespace, platform=args.platform))
|
|
400
398
|
if args.set:
|
|
401
399
|
resolved.update(_parse_keyvals(args.set))
|
|
402
400
|
if args.encrypt:
|
|
@@ -456,9 +454,7 @@ def main():
|
|
|
456
454
|
print(source.path)
|
|
457
455
|
|
|
458
456
|
elif args.unresolved:
|
|
459
|
-
env = load_environ(
|
|
460
|
-
args.namespace, platform=args.platform, encrypt=args.encrypt
|
|
461
|
-
)
|
|
457
|
+
env = load_environ(args.namespace, platform=args.platform, encrypt=args.encrypt)
|
|
462
458
|
for k, v in sorted(env.items(), key=lambda x: str(x[0])):
|
|
463
459
|
print(f"{k}={v}")
|
|
464
460
|
|
|
@@ -53,6 +53,7 @@ padding = None
|
|
|
53
53
|
Cipher = None
|
|
54
54
|
algorithms = None
|
|
55
55
|
modes = None
|
|
56
|
+
default_backend = None
|
|
56
57
|
try:
|
|
57
58
|
# cryptography emits a Python 3.8 deprecation warning during import.
|
|
58
59
|
with warnings.catch_warnings():
|
|
@@ -62,8 +63,9 @@ try:
|
|
|
62
63
|
category=DeprecationWarning,
|
|
63
64
|
module=r"cryptography(\..*)?$",
|
|
64
65
|
)
|
|
65
|
-
from cryptography.fernet import Fernet, InvalidToken
|
|
66
66
|
from cryptography.exceptions import InvalidTag
|
|
67
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
68
|
+
from cryptography.hazmat.backends import default_backend
|
|
67
69
|
from cryptography.hazmat.primitives import padding
|
|
68
70
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
69
71
|
|
|
@@ -214,7 +216,7 @@ class AESGCMEncryptor(object):
|
|
|
214
216
|
:return: Dictionary containing nonce, ciphertext, and tag.
|
|
215
217
|
"""
|
|
216
218
|
nonce = os.urandom(12) # GCM requires a 12-byte nonce
|
|
217
|
-
cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce))
|
|
219
|
+
cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce), backend=default_backend())
|
|
218
220
|
encryptor = cipher.encryptor()
|
|
219
221
|
padded_data = pad_data(secret)
|
|
220
222
|
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
|
@@ -235,7 +237,11 @@ class AESGCMEncryptor(object):
|
|
|
235
237
|
nonce = b64decode(encrypted_data["nonce"])
|
|
236
238
|
ciphertext = b64decode(encrypted_data["ciphertext"])
|
|
237
239
|
tag = b64decode(encrypted_data["tag"])
|
|
238
|
-
cipher = Cipher(
|
|
240
|
+
cipher = Cipher(
|
|
241
|
+
algorithms.AES(self.key),
|
|
242
|
+
modes.GCM(nonce, tag),
|
|
243
|
+
backend=default_backend(),
|
|
244
|
+
)
|
|
239
245
|
decryptor = cipher.decryptor()
|
|
240
246
|
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
|
|
241
247
|
return unpad_data(padded_data)
|
|
@@ -43,12 +43,7 @@ import yaml # noqa
|
|
|
43
43
|
|
|
44
44
|
from envstack import config, logger, path, util
|
|
45
45
|
from envstack.exceptions import * # noqa
|
|
46
|
-
from envstack.node import
|
|
47
|
-
BaseNode,
|
|
48
|
-
EncryptedNode,
|
|
49
|
-
custom_node_types,
|
|
50
|
-
get_keys_from_env,
|
|
51
|
-
)
|
|
46
|
+
from envstack.node import BaseNode, EncryptedNode, custom_node_types, get_keys_from_env
|
|
52
47
|
|
|
53
48
|
# value delimiter pattern (splits values by os.pathsep)
|
|
54
49
|
delimiter_pattern = re.compile("(?![^{]*})[;:]+")
|
|
@@ -468,9 +463,7 @@ def get_sources(
|
|
|
468
463
|
logger.log.warning(f"Error accessing {potential_file}")
|
|
469
464
|
continue
|
|
470
465
|
if not found_files and not ignore_missing:
|
|
471
|
-
raise TemplateNotFound( # noqa
|
|
472
|
-
f"{file_basename} not found in ENVPATH or scope."
|
|
473
|
-
)
|
|
466
|
+
raise TemplateNotFound(f"{file_basename} not found in ENVPATH or scope.") # noqa
|
|
474
467
|
return found_files
|
|
475
468
|
|
|
476
469
|
def _load_file(file_basename):
|
|
@@ -787,9 +780,7 @@ def bake_environ(
|
|
|
787
780
|
return load_environ(name, scope=scope).bake(filename, depth, encrypt)
|
|
788
781
|
|
|
789
782
|
|
|
790
|
-
def encrypt_environ(
|
|
791
|
-
env: dict, node_class: BaseNode = EncryptedNode, encrypt: bool = True
|
|
792
|
-
):
|
|
783
|
+
def encrypt_environ(env: dict, node_class: BaseNode = EncryptedNode, encrypt: bool = True):
|
|
793
784
|
"""Encrypts all values in a given environment, returning a new environment.
|
|
794
785
|
Looks for encryption keys in the environment.
|
|
795
786
|
|
|
@@ -306,9 +306,7 @@ class CustomDumper(yaml.SafeDumper):
|
|
|
306
306
|
|
|
307
307
|
# set basekey for the node
|
|
308
308
|
if self.depth == 2:
|
|
309
|
-
assert isinstance(node, yaml.ScalarNode),
|
|
310
|
-
"yaml node not a string: %s" % node
|
|
311
|
-
)
|
|
309
|
+
assert isinstance(node, yaml.ScalarNode), "yaml node not a string: %s" % node
|
|
312
310
|
self.basekey = str(node.value)
|
|
313
311
|
node.value = self.basekey
|
|
314
312
|
|
|
@@ -189,9 +189,7 @@ class Path(str):
|
|
|
189
189
|
from_env = _load_resolved_stack(stack, platform=self.platform, scope=scope)
|
|
190
190
|
to_env = _load_resolved_stack(stack, platform=platform, scope=scope)
|
|
191
191
|
except Exception as err:
|
|
192
|
-
raise InvalidPath(
|
|
193
|
-
f"Failed to load stack '{stack}' for platform conversion: {err}"
|
|
194
|
-
)
|
|
192
|
+
raise InvalidPath(f"Failed to load stack '{stack}' for platform conversion: {err}")
|
|
195
193
|
|
|
196
194
|
from_root = from_env.get(root_var)
|
|
197
195
|
to_root = to_env.get(root_var)
|
|
@@ -335,15 +333,11 @@ def extract_fields(
|
|
|
335
333
|
try:
|
|
336
334
|
p = Path(filepath, platform=platform)
|
|
337
335
|
if isinstance(template, str):
|
|
338
|
-
template = get_template(
|
|
339
|
-
template, stack=stack, platform=platform, scope=p.scope()
|
|
340
|
-
)
|
|
336
|
+
template = get_template(template, stack=stack, platform=platform, scope=p.scope())
|
|
341
337
|
return template.get_fields(filepath)
|
|
342
338
|
|
|
343
339
|
except InvalidPath: # noqa
|
|
344
|
-
logger.log.debug(
|
|
345
|
-
"path does not match template: {0} {1}".format(template, filepath)
|
|
346
|
-
)
|
|
340
|
+
logger.log.debug("path does not match template: {0} {1}".format(template, filepath))
|
|
347
341
|
return {}
|
|
348
342
|
|
|
349
343
|
except Exception as err:
|
|
@@ -45,13 +45,7 @@ from collections import OrderedDict
|
|
|
45
45
|
import yaml
|
|
46
46
|
|
|
47
47
|
from envstack import config
|
|
48
|
-
from envstack.node import
|
|
49
|
-
AESGCMNode,
|
|
50
|
-
Base64Node,
|
|
51
|
-
EncryptedNode,
|
|
52
|
-
FernetNode,
|
|
53
|
-
CustomLoader,
|
|
54
|
-
)
|
|
48
|
+
from envstack.node import AESGCMNode, Base64Node, CustomLoader, EncryptedNode, FernetNode
|
|
55
49
|
|
|
56
50
|
# default memoization cache timeout in seconds
|
|
57
51
|
CACHE_TIMEOUT = 5
|
|
@@ -63,9 +57,7 @@ null = ""
|
|
|
63
57
|
drive_letter_pattern = re.compile(r"(?P<sep>[:;])?(?P<drive>[A-Z]:[/\\])")
|
|
64
58
|
|
|
65
59
|
# regular expression pattern for bash-like variable expansion
|
|
66
|
-
variable_pattern = re.compile(
|
|
67
|
-
r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}"
|
|
68
|
-
)
|
|
60
|
+
variable_pattern = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}")
|
|
69
61
|
|
|
70
62
|
# regular expression pattern for command substitution
|
|
71
63
|
cmdsub_pattern = re.compile(r"^\s*\$\((?P<cmd>.*)\)\s*$", re.DOTALL)
|
|
@@ -172,9 +164,7 @@ def split_windows_paths(path_str: str):
|
|
|
172
164
|
result += [
|
|
173
165
|
p
|
|
174
166
|
for part in modified.split("|")
|
|
175
|
-
for p in re.split(
|
|
176
|
-
r"(?<![A-Z]):", part
|
|
177
|
-
) # capture colons not in drive letters
|
|
167
|
+
for p in re.split(r"(?<![A-Z]):", part) # capture colons not in drive letters
|
|
178
168
|
if p
|
|
179
169
|
]
|
|
180
170
|
else:
|
|
@@ -271,9 +261,7 @@ def dict_diff(dict1: dict, dict2: dict):
|
|
|
271
261
|
"""
|
|
272
262
|
added = {k: dict2[k] for k in dict2 if k not in dict1}
|
|
273
263
|
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
|
-
}
|
|
264
|
+
changed = {k: (dict1[k], dict2[k]) for k in dict1 if k in dict2 and dict1[k] != dict2[k]}
|
|
277
265
|
unchanged = {k: dict1[k] for k in dict1 if k in dict2 and dict1[k] == dict2[k]}
|
|
278
266
|
|
|
279
267
|
return {
|
|
@@ -296,9 +284,7 @@ def encode(env: dict):
|
|
|
296
284
|
return dict((c(k), c(v)) for k, v in env.items())
|
|
297
285
|
|
|
298
286
|
|
|
299
|
-
def get_paths_from_var(
|
|
300
|
-
var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True
|
|
301
|
-
):
|
|
287
|
+
def get_paths_from_var(var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True):
|
|
302
288
|
"""Returns a list of paths from a given pathsep separated environment
|
|
303
289
|
variable.
|
|
304
290
|
|
|
@@ -361,11 +347,7 @@ def evaluate_modifiers(
|
|
|
361
347
|
resolving = set()
|
|
362
348
|
|
|
363
349
|
def sanitize_value(value):
|
|
364
|
-
if (
|
|
365
|
-
isinstance(value, str)
|
|
366
|
-
and value.endswith("}")
|
|
367
|
-
and not value.startswith("${")
|
|
368
|
-
):
|
|
350
|
+
if isinstance(value, str) and value.endswith("}") and not value.startswith("${"):
|
|
369
351
|
# trim a dangling '}' when there's no matching '{'
|
|
370
352
|
if value.count("{") == 0 and value.count("}") == 1:
|
|
371
353
|
return value.rstrip("}")
|
|
@@ -393,9 +375,7 @@ def evaluate_modifiers(
|
|
|
393
375
|
argument = match.group(3) # may be None
|
|
394
376
|
parent_value = parent.get(var_name, "")
|
|
395
377
|
override = os.getenv(var_name, parent_value)
|
|
396
|
-
current = str(
|
|
397
|
-
environ.get(var_name, parent_value or "")
|
|
398
|
-
) # current value we have
|
|
378
|
+
current = str(environ.get(var_name, parent_value or "")) # current value we have
|
|
399
379
|
varstr = "${%s}" % var_name
|
|
400
380
|
|
|
401
381
|
# cycle / self-reference guard
|
|
@@ -501,9 +481,7 @@ def evaluate_modifiers(
|
|
|
501
481
|
elif isinstance(expression, list):
|
|
502
482
|
result = [evaluate_modifiers(v, environ, parent) for v in expression]
|
|
503
483
|
elif isinstance(expression, dict):
|
|
504
|
-
result = {
|
|
505
|
-
k: evaluate_modifiers(v, environ, parent) for k, v in expression.items()
|
|
506
|
-
}
|
|
484
|
+
result = {k: evaluate_modifiers(v, environ, parent) for k, v in expression.items()}
|
|
507
485
|
else:
|
|
508
486
|
result = expression
|
|
509
487
|
|
|
@@ -539,9 +517,7 @@ def evaluate_command(command: str):
|
|
|
539
517
|
return command
|
|
540
518
|
|
|
541
519
|
|
|
542
|
-
def load_sys_path(
|
|
543
|
-
var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True
|
|
544
|
-
):
|
|
520
|
+
def load_sys_path(var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True):
|
|
545
521
|
"""
|
|
546
522
|
Add paths from the given environment variable to sys.path.
|
|
547
523
|
|
|
@@ -0,0 +1,333 @@
|
|
|
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 executable wrapper classes and functions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shlex
|
|
39
|
+
import subprocess
|
|
40
|
+
import traceback
|
|
41
|
+
|
|
42
|
+
from envstack import config, logger
|
|
43
|
+
from envstack.env import load_environ, resolve_environ
|
|
44
|
+
from envstack.util import encode, evaluate_modifiers
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_args(cmd: str):
|
|
48
|
+
"""
|
|
49
|
+
Converts a command line string to an arg list to be passed to
|
|
50
|
+
subprocess.Popen that preserves args with quotes.
|
|
51
|
+
|
|
52
|
+
:param cmd: command line string.
|
|
53
|
+
"""
|
|
54
|
+
return shlex.split(cmd)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def shell_join(args):
|
|
58
|
+
"""
|
|
59
|
+
Joins a list of arguments into a single quoted shell string.
|
|
60
|
+
|
|
61
|
+
:param args: list of arguments.
|
|
62
|
+
:returns: shell string.
|
|
63
|
+
"""
|
|
64
|
+
argstr = ".".join(args)
|
|
65
|
+
if '"' in argstr or "'" in argstr:
|
|
66
|
+
try:
|
|
67
|
+
return shlex.join(args)
|
|
68
|
+
except AttributeError:
|
|
69
|
+
return " ".join(shlex.quote(arg) for arg in args)
|
|
70
|
+
else:
|
|
71
|
+
return " ".join(args)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Wrapper(object):
|
|
75
|
+
"""Wrapper class for executables. Subprocessed with preconfigured environment.
|
|
76
|
+
|
|
77
|
+
Subclasses should override executable():
|
|
78
|
+
|
|
79
|
+
class ToolWrapper(Wrapper):
|
|
80
|
+
def __init__(self, namespace, args):
|
|
81
|
+
super(ToolWrapper, self).__init__(namespace, args)
|
|
82
|
+
def executable(self):
|
|
83
|
+
return '$TOOL_ROOT/bin/tool'
|
|
84
|
+
|
|
85
|
+
To launch 'tool', create an instance of the wrapper, and pass as the first
|
|
86
|
+
argument the namespace continaining config options, then call launch():
|
|
87
|
+
|
|
88
|
+
tool = ToolWrapper('tool', sys.argv[1:])
|
|
89
|
+
tool.launch()
|
|
90
|
+
|
|
91
|
+
The log attribute can be set to a custom logger:
|
|
92
|
+
|
|
93
|
+
tool.log = MyLogger()
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
shell: bool = False
|
|
97
|
+
|
|
98
|
+
def __init__(self, namespace, args=[]):
|
|
99
|
+
"""Initializes the wrapper with the given namespace and args.
|
|
100
|
+
|
|
101
|
+
:param namespace: environment stack namespace.
|
|
102
|
+
:param args: command line arguments.
|
|
103
|
+
"""
|
|
104
|
+
super(Wrapper, self).__init__()
|
|
105
|
+
self.args = args
|
|
106
|
+
self.name = namespace
|
|
107
|
+
self.shell = self.shell
|
|
108
|
+
self.log = logger.log
|
|
109
|
+
self.env = load_environ(namespace)
|
|
110
|
+
|
|
111
|
+
def executable(self):
|
|
112
|
+
"""Returns the path to the executable."""
|
|
113
|
+
raise NotImplementedError
|
|
114
|
+
|
|
115
|
+
def launch(self):
|
|
116
|
+
"""Launches the wrapped tool in a subprocess with env."""
|
|
117
|
+
env = self.get_subprocess_env()
|
|
118
|
+
command = self.get_subprocess_command(env)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
proc = subprocess.run(
|
|
122
|
+
command,
|
|
123
|
+
env=encode(env),
|
|
124
|
+
shell=self.shell,
|
|
125
|
+
check=False,
|
|
126
|
+
)
|
|
127
|
+
return proc.returncode
|
|
128
|
+
except Exception:
|
|
129
|
+
traceback.print_exc()
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
def get_subprocess_args(self, cmd):
|
|
133
|
+
"""Returns the arguments to be passed to the subprocess."""
|
|
134
|
+
return self.args
|
|
135
|
+
|
|
136
|
+
def get_subprocess_command(self, env):
|
|
137
|
+
"""Returns argv (preferred) or a command string if shell=True."""
|
|
138
|
+
cmd = evaluate_modifiers(self.executable(), env)
|
|
139
|
+
args = self.get_subprocess_args(cmd)
|
|
140
|
+
|
|
141
|
+
if self.shell:
|
|
142
|
+
return " ".join([cmd] + args)
|
|
143
|
+
|
|
144
|
+
return [cmd] + args
|
|
145
|
+
|
|
146
|
+
def get_subprocess_env(self):
|
|
147
|
+
"""
|
|
148
|
+
Returns the environment that gets passed to the subprocess when launch()
|
|
149
|
+
is called on the wrapper.
|
|
150
|
+
"""
|
|
151
|
+
env = os.environ.copy()
|
|
152
|
+
env.update(resolve_environ(self.env))
|
|
153
|
+
return env
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CommandWrapper(Wrapper):
|
|
157
|
+
"""Wrapper class for running wrapped commands from the command-line."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, namespace=config.DEFAULT_NAMESPACE, args=[]):
|
|
160
|
+
"""
|
|
161
|
+
Initializes the command wrapper with the given namespace and args, e.g.:
|
|
162
|
+
|
|
163
|
+
>>> cmd = CommandWrapper(stack, ['ls', '-al'])
|
|
164
|
+
>>> print(cmd.executable())
|
|
165
|
+
ls
|
|
166
|
+
>>> print(cmd.args)
|
|
167
|
+
['-al']
|
|
168
|
+
|
|
169
|
+
:param namespace: environment stack name (default: 'default').
|
|
170
|
+
:param args: command and arguments as a list.
|
|
171
|
+
"""
|
|
172
|
+
super(CommandWrapper, self).__init__(namespace, args)
|
|
173
|
+
self.cmd = list(args)
|
|
174
|
+
|
|
175
|
+
def executable(self):
|
|
176
|
+
"""Returns the command to run."""
|
|
177
|
+
return self.cmd[0]
|
|
178
|
+
|
|
179
|
+
def get_subprocess_args(self, cmd):
|
|
180
|
+
return self.cmd[1:]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ShellWrapper(Wrapper):
|
|
184
|
+
"""
|
|
185
|
+
Runs a *string expression* under the user's shell using -c.
|
|
186
|
+
Useful for wrapping shell expressions that need variable expansion:
|
|
187
|
+
|
|
188
|
+
>>> shell = ShellWrapper('default', 'echo {HOME}')
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(self, namespace=config.DEFAULT_NAMESPACE, expr: str = ""):
|
|
192
|
+
super().__init__(namespace, args=[])
|
|
193
|
+
self.expr = expr
|
|
194
|
+
|
|
195
|
+
def get_interactive(self, env: dict = os.environ):
|
|
196
|
+
override = env.get("INTERACTIVE")
|
|
197
|
+
if override is not None:
|
|
198
|
+
return str(override).lower() in {"1", "true", "yes", "on"}
|
|
199
|
+
return False # default safe
|
|
200
|
+
|
|
201
|
+
def get_subprocess_command(self, env: dict = os.environ):
|
|
202
|
+
interactive = self.get_interactive(env)
|
|
203
|
+
if interactive:
|
|
204
|
+
return [config.SHELL, "-i", "-c", self.expr]
|
|
205
|
+
return [config.SHELL, "-c", self.expr]
|
|
206
|
+
|
|
207
|
+
def executable(self):
|
|
208
|
+
"""Returns the shell command to run the original command."""
|
|
209
|
+
return self.cmd
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class CmdWrapper(CommandWrapper):
|
|
213
|
+
"""Wrapper class for running wrapped commands in Windows cmd.exe."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, namespace=config.DEFAULT_NAMESPACE, args=[]):
|
|
216
|
+
super().__init__(namespace, args)
|
|
217
|
+
|
|
218
|
+
# Always run through cmd.exe explicitly
|
|
219
|
+
self.shell = False
|
|
220
|
+
self._cmd_exe = config.SHELL # expected: "cmd" or "cmd.exe"
|
|
221
|
+
|
|
222
|
+
# Join the intended argv into one command-line string for /c
|
|
223
|
+
cmdline = shell_join(self.cmd)
|
|
224
|
+
|
|
225
|
+
# cmd.exe /c <command>
|
|
226
|
+
self._subprocess_argv = [self._cmd_exe, "/c", cmdline]
|
|
227
|
+
|
|
228
|
+
def executable(self):
|
|
229
|
+
return self._cmd_exe
|
|
230
|
+
|
|
231
|
+
def get_subprocess_args(self, cmd):
|
|
232
|
+
# Not used (we override get_subprocess_command)
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
def get_subprocess_command(self, env):
|
|
236
|
+
return list(self._subprocess_argv)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def capture_output(
|
|
240
|
+
command: str,
|
|
241
|
+
namespace: str = config.DEFAULT_NAMESPACE,
|
|
242
|
+
timeout: int = config.COMMAND_TIMEOUT,
|
|
243
|
+
):
|
|
244
|
+
"""
|
|
245
|
+
Runs a command (string or argv) with the given stack namespace and captures stdout/stderr.
|
|
246
|
+
|
|
247
|
+
Returns: (returncode, stdout, stderr)
|
|
248
|
+
"""
|
|
249
|
+
import errno
|
|
250
|
+
|
|
251
|
+
shellname = os.path.basename(config.SHELL).lower()
|
|
252
|
+
argv = list(command) if isinstance(command, (list, tuple)) else to_args(command)
|
|
253
|
+
|
|
254
|
+
# build env exactly like Wrapper.launch()
|
|
255
|
+
env = os.environ.copy()
|
|
256
|
+
env.update(resolve_environ(load_environ(namespace)))
|
|
257
|
+
|
|
258
|
+
# prefer argv execution where possible
|
|
259
|
+
if shellname in ["bash", "sh", "zsh"]:
|
|
260
|
+
needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
|
|
261
|
+
if needs_shell:
|
|
262
|
+
expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
|
|
263
|
+
expr = shell_join(expr_argv)
|
|
264
|
+
cmd = [config.SHELL, "-c", expr]
|
|
265
|
+
else:
|
|
266
|
+
cmd = argv
|
|
267
|
+
|
|
268
|
+
# for cmd always use original command string
|
|
269
|
+
elif shellname in ["cmd"]:
|
|
270
|
+
cmd = command
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
cmd = argv
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
proc = subprocess.run(
|
|
277
|
+
cmd,
|
|
278
|
+
env=encode(env),
|
|
279
|
+
shell=False,
|
|
280
|
+
check=False,
|
|
281
|
+
capture_output=True,
|
|
282
|
+
text=True,
|
|
283
|
+
timeout=timeout,
|
|
284
|
+
)
|
|
285
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
286
|
+
except FileNotFoundError as e:
|
|
287
|
+
# no process ran; synthesize a bash-like error and code
|
|
288
|
+
# 127 is the conventional "command not found" code in shells
|
|
289
|
+
missing = e.filename or (cmd[0] if isinstance(cmd, list) and cmd else str(command))
|
|
290
|
+
return 127, "", f"{missing}: command not found"
|
|
291
|
+
except OSError as e:
|
|
292
|
+
# Other OS-level execution errors (permission, exec format, etc.)
|
|
293
|
+
rc = 126 if getattr(e, "errno", None) in (errno.EACCES,) else 1
|
|
294
|
+
return rc, "", str(e)
|
|
295
|
+
except subprocess.TimeoutExpired:
|
|
296
|
+
return 124, "", f"Command timed out after {timeout} seconds"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):
|
|
300
|
+
"""
|
|
301
|
+
Runs a given command with the given stack namespace.
|
|
302
|
+
|
|
303
|
+
>>> run_command(['ls', '-l'])
|
|
304
|
+
|
|
305
|
+
Or to run in a specific stack namespace:
|
|
306
|
+
|
|
307
|
+
>>> run_command(['ls', '-l'], 'my-stack')
|
|
308
|
+
|
|
309
|
+
- Automatically detects the shell to use.
|
|
310
|
+
- Converts {VAR} to $VAR for bash, sh, zsh, and %VAR% for cmd.
|
|
311
|
+
|
|
312
|
+
:param command: command to run as a list of arguments.
|
|
313
|
+
:param namespace: environment stack name (default: 'default').
|
|
314
|
+
:returns: command exit code
|
|
315
|
+
"""
|
|
316
|
+
logger.setup_stream_handler()
|
|
317
|
+
shellname = os.path.basename(config.SHELL).lower()
|
|
318
|
+
argv = list(command) if isinstance(command, (list, tuple)) else to_args(command)
|
|
319
|
+
|
|
320
|
+
if shellname in ["bash", "sh", "zsh"]:
|
|
321
|
+
needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
|
|
322
|
+
if needs_shell:
|
|
323
|
+
expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
|
|
324
|
+
expr = shell_join(expr_argv)
|
|
325
|
+
return ShellWrapper(namespace, expr).launch()
|
|
326
|
+
|
|
327
|
+
return CommandWrapper(namespace, argv).launch()
|
|
328
|
+
|
|
329
|
+
if shellname in ["cmd"]:
|
|
330
|
+
expr = [re.sub(r"\{(\w+)\}", r"%\1%", a) for a in argv]
|
|
331
|
+
return CmdWrapper(namespace, expr).launch()
|
|
332
|
+
|
|
333
|
+
return CommandWrapper(namespace, argv).launch()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: envstack
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Environment variable composition layer for tools and processes.
|
|
5
5
|
Author-email: Ryan Galloway <ryan@rsgalloway.com>
|
|
6
6
|
License: BSD 3-Clause License
|
|
@@ -29,6 +29,16 @@ License-File: LICENSE
|
|
|
29
29
|
Requires-Dist: PyYAML>=5.1.2
|
|
30
30
|
Requires-Dist: cryptography<47,>=43.0.1; python_version < "3.9"
|
|
31
31
|
Requires-Dist: cryptography>=43.0.1; python_version >= "3.9"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest; extra == "dev"
|
|
34
|
+
Requires-Dist: flake8==7.1.1; extra == "dev"
|
|
35
|
+
Requires-Dist: mccabe==0.7.0; extra == "dev"
|
|
36
|
+
Requires-Dist: isort==5.13.2; extra == "dev"
|
|
37
|
+
Requires-Dist: black==24.8.0; extra == "dev"
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<img src="docs/assets/envstack.png" alt="envstack logo" width="560">
|
|
41
|
+
</p>
|
|
32
42
|
|
|
33
43
|
envstack
|
|
34
44
|
========
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
README.md
|
|
3
|
-
dist.json
|
|
4
3
|
pyproject.toml
|
|
5
4
|
lib/envstack/__init__.py
|
|
6
5
|
lib/envstack/cli.py
|
|
@@ -26,5 +25,6 @@ tests/test_encrypt.py
|
|
|
26
25
|
tests/test_env.py
|
|
27
26
|
tests/test_node.py
|
|
28
27
|
tests/test_path.py
|
|
28
|
+
tests/test_performance.py
|
|
29
29
|
tests/test_util.py
|
|
30
30
|
tests/test_wrapper.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "envstack"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "Environment variable composition layer for tools and processes."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.6"
|
|
@@ -56,8 +56,33 @@ Documentation = "https://github.com/rsgalloway/envstack/tree/master/docs"
|
|
|
56
56
|
envstack = "envstack.cli:main"
|
|
57
57
|
whichenv = "envstack.cli:whichenv"
|
|
58
58
|
|
|
59
|
+
[project.optional-dependencies]
|
|
60
|
+
dev = [
|
|
61
|
+
"pytest",
|
|
62
|
+
"flake8==7.1.1",
|
|
63
|
+
"mccabe==0.7.0",
|
|
64
|
+
"isort==5.13.2",
|
|
65
|
+
"black==24.8.0",
|
|
66
|
+
]
|
|
67
|
+
|
|
59
68
|
[tool.setuptools]
|
|
60
69
|
zip-safe = false
|
|
61
70
|
|
|
62
71
|
[tool.setuptools.packages.find]
|
|
63
72
|
where = ["lib"]
|
|
73
|
+
|
|
74
|
+
[tool.isort]
|
|
75
|
+
profile = "black"
|
|
76
|
+
line_length = 100
|
|
77
|
+
skip_gitignore = true
|
|
78
|
+
|
|
79
|
+
[tool.black]
|
|
80
|
+
line-length = 100
|
|
81
|
+
target-version = ["py38"]
|
|
82
|
+
|
|
83
|
+
[tool.pytest.ini_options]
|
|
84
|
+
markers = [
|
|
85
|
+
"integration: tests that run real subprocesses (slower / OS-dependent)",
|
|
86
|
+
]
|
|
87
|
+
testpaths = ["tests"]
|
|
88
|
+
norecursedirs = ["tmp"]
|
|
@@ -35,18 +35,18 @@ Contains unit tests for running commands.
|
|
|
35
35
|
|
|
36
36
|
import os
|
|
37
37
|
import platform
|
|
38
|
-
import pytest
|
|
39
38
|
import shutil
|
|
40
39
|
import subprocess
|
|
41
40
|
import sys
|
|
42
41
|
import tempfile
|
|
43
42
|
import unittest
|
|
44
43
|
|
|
44
|
+
import pytest
|
|
45
|
+
from helpers import create_test_root, update_env_file
|
|
46
|
+
|
|
45
47
|
import envstack
|
|
46
48
|
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
|
|
47
49
|
|
|
48
|
-
from test_env import create_test_root, update_env_file
|
|
49
|
-
|
|
50
50
|
pytestmark = pytest.mark.skipif(
|
|
51
51
|
sys.platform != "linux",
|
|
52
52
|
reason="Linux-only shell integration tests (bash/ls/PS1, paths, env layout)",
|
|
@@ -99,9 +99,7 @@ STACK=default
|
|
|
99
99
|
"""
|
|
100
100
|
% self.root
|
|
101
101
|
)
|
|
102
|
-
output = subprocess.check_output(
|
|
103
|
-
self.envstack_bin, shell=True, universal_newlines=True
|
|
104
|
-
)
|
|
102
|
+
output = subprocess.check_output(self.envstack_bin, shell=True, universal_newlines=True)
|
|
105
103
|
self.assertEqual(output, expected_output)
|
|
106
104
|
|
|
107
105
|
def test_dev(self):
|
|
@@ -231,7 +229,7 @@ STACK=ZGVmYXVsdA==
|
|
|
231
229
|
|
|
232
230
|
def test_hello_command_echo(self):
|
|
233
231
|
"""Tests that resolved and encrypted values resolve in subprocesses."""
|
|
234
|
-
expected_output =
|
|
232
|
+
expected_output = "goodbye\n"
|
|
235
233
|
command = "%s thing -r HELLO -e -- echo {HELLO}" % self.envstack_bin
|
|
236
234
|
output = subprocess.check_output(command, shell=True, universal_newlines=True)
|
|
237
235
|
self.assertEqual(output, expected_output)
|
|
@@ -310,8 +308,7 @@ ROOT={self.root}
|
|
|
310
308
|
STACK=project
|
|
311
309
|
"""
|
|
312
310
|
command = (
|
|
313
|
-
"ENV=blah ROOT=/var/tmp %s project -r DEPLOY_ROOT HELLO ROOT STACK"
|
|
314
|
-
% self.envstack_bin
|
|
311
|
+
"ENV=blah ROOT=/var/tmp %s project -r DEPLOY_ROOT HELLO ROOT STACK" % self.envstack_bin
|
|
315
312
|
)
|
|
316
313
|
output = subprocess.check_output(command, shell=True, universal_newlines=True)
|
|
317
314
|
self.assertEqual(output, expected_output)
|
|
@@ -426,9 +423,7 @@ windows:
|
|
|
426
423
|
|
|
427
424
|
def test_thing(self):
|
|
428
425
|
"""Tests baking the thing stack with depth of 1."""
|
|
429
|
-
command = make_command(
|
|
430
|
-
self.envstack_bin, self.filename, "thing", "--depth", "1"
|
|
431
|
-
)
|
|
426
|
+
command = make_command(self.envstack_bin, self.filename, "thing", "--depth", "1")
|
|
432
427
|
expected_output = r"""#!/usr/bin/env envstack
|
|
433
428
|
include: [default]
|
|
434
429
|
all: &all
|
|
@@ -34,13 +34,8 @@ Contains unit tests for the encrypt.py module.
|
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
36
|
import unittest
|
|
37
|
-
from unittest.mock import patch
|
|
38
37
|
|
|
39
|
-
from envstack.encrypt import
|
|
40
|
-
AESGCMEncryptor,
|
|
41
|
-
Base64Encryptor,
|
|
42
|
-
FernetEncryptor,
|
|
43
|
-
)
|
|
38
|
+
from envstack.encrypt import AESGCMEncryptor, Base64Encryptor, FernetEncryptor
|
|
44
39
|
|
|
45
40
|
|
|
46
41
|
class TestBase64Encryptor(unittest.TestCase):
|
|
@@ -37,46 +37,18 @@ import os
|
|
|
37
37
|
import shutil
|
|
38
38
|
import sys
|
|
39
39
|
import unittest
|
|
40
|
-
|
|
40
|
+
|
|
41
|
+
from helpers import create_test_root, update_env_file
|
|
41
42
|
|
|
42
43
|
import envstack
|
|
43
|
-
from envstack.env import Env, EnvVar, Scope, Source
|
|
44
44
|
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
|
|
45
|
+
from envstack.env import Env, EnvVar, Scope, Source
|
|
45
46
|
from envstack.util import dict_diff
|
|
46
47
|
|
|
47
48
|
# path to the env directory
|
|
48
49
|
envpath = os.path.join(os.path.dirname(__file__), "fixtures", "env")
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
def create_test_root():
|
|
52
|
-
"""Creates a temporary directory with the contents of the "env" folder."""
|
|
53
|
-
|
|
54
|
-
# create a temporary directory
|
|
55
|
-
root = tempfile.mkdtemp()
|
|
56
|
-
|
|
57
|
-
for env in ("prod", "dev"):
|
|
58
|
-
shutil.copytree(envpath, os.path.join(root, env, "env"))
|
|
59
|
-
|
|
60
|
-
return root
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def update_env_file(file_path: str, key: str, value: str):
|
|
64
|
-
"""Updates a key in a YAML file with a new value."""
|
|
65
|
-
import yaml
|
|
66
|
-
|
|
67
|
-
# read the YAML file
|
|
68
|
-
with open(file_path, "r") as f:
|
|
69
|
-
data = yaml.safe_load(f)
|
|
70
|
-
|
|
71
|
-
for _, env_config in data.items():
|
|
72
|
-
if isinstance(env_config, dict) and key in env_config:
|
|
73
|
-
env_config[key] = value
|
|
74
|
-
|
|
75
|
-
# write the modified data back to the file
|
|
76
|
-
with open(file_path, "w") as f:
|
|
77
|
-
yaml.safe_dump(data, f, sort_keys=False)
|
|
78
|
-
|
|
79
|
-
|
|
80
52
|
class TestEnvVar(unittest.TestCase):
|
|
81
53
|
def test_init(self):
|
|
82
54
|
v = EnvVar("$FOO:${BAR}")
|
|
@@ -382,7 +354,7 @@ class TestResolveEnviron(unittest.TestCase):
|
|
|
382
354
|
os.environ["CUSTOM"] = "/var/tmp"
|
|
383
355
|
env = {"FOO": "${CUSTOM}/foo"}
|
|
384
356
|
resolved = resolve_environ(env)
|
|
385
|
-
self.assertEqual(resolved["FOO"],
|
|
357
|
+
self.assertEqual(resolved["FOO"], "/var/tmp/foo")
|
|
386
358
|
|
|
387
359
|
def test_simple(self):
|
|
388
360
|
"""Tests resolving a simple environment."""
|
|
@@ -738,7 +710,7 @@ class TestEncryptEnviron(unittest.TestCase):
|
|
|
738
710
|
|
|
739
711
|
def encrypt_environ(self, stack_name):
|
|
740
712
|
"""Tests load_environ with encryption (Base64 only)."""
|
|
741
|
-
from envstack.env import
|
|
713
|
+
from envstack.env import encrypt_environ, load_environ
|
|
742
714
|
from envstack.node import EncryptedNode
|
|
743
715
|
|
|
744
716
|
env = load_environ(stack_name)
|
|
@@ -817,8 +789,8 @@ class TestEncryptEnviron(unittest.TestCase):
|
|
|
817
789
|
|
|
818
790
|
def resolve_encrypted_environ(self, stack_name):
|
|
819
791
|
"""Tests resolve_environ with encrypted environ."""
|
|
820
|
-
from envstack.env import encrypt_environ, load_environ, resolve_environ
|
|
821
792
|
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
|
|
793
|
+
from envstack.env import encrypt_environ, load_environ, resolve_environ
|
|
822
794
|
|
|
823
795
|
env = load_environ(stack_name) # unresolved values
|
|
824
796
|
resolved = resolve_environ(env) # resolved values
|
|
@@ -839,18 +811,14 @@ class TestEncryptEnviron(unittest.TestCase):
|
|
|
839
811
|
continue
|
|
840
812
|
encrypted_value = encrypted[key] # encrypted value
|
|
841
813
|
resolved_value = resolved[key] # resolved value
|
|
842
|
-
encrypted_resolved_value = encrypted_resolved[
|
|
843
|
-
key
|
|
844
|
-
] # resolved encrypted value
|
|
814
|
+
encrypted_resolved_value = encrypted_resolved[key] # resolved encrypted value
|
|
845
815
|
self.assertNotEqual(encrypted_value, None)
|
|
846
816
|
self.assertNotEqual(resolved_value, None)
|
|
847
817
|
self.assertNotEqual(encrypted_resolved_value, None)
|
|
848
818
|
self.assertNotEqual(encrypted_value, value)
|
|
849
819
|
self.assertNotEqual(encrypted_value, resolved_value)
|
|
850
820
|
# self.assertEqual(resolved_value, encrypted_resolved_value)
|
|
851
|
-
if isinstance(resolved_value, str) and isinstance(
|
|
852
|
-
encrypted_resolved_value, str
|
|
853
|
-
):
|
|
821
|
+
if isinstance(resolved_value, str) and isinstance(encrypted_resolved_value, str):
|
|
854
822
|
self.assertTrue(
|
|
855
823
|
resolved_value.startswith(encrypted_resolved_value),
|
|
856
824
|
f"{encrypted_resolved_value} not prefix of {resolved_value}",
|
|
@@ -1040,7 +1008,7 @@ class TestIssues(unittest.TestCase):
|
|
|
1040
1008
|
|
|
1041
1009
|
The STACK name and the test env file name should be the same.
|
|
1042
1010
|
"""
|
|
1043
|
-
from envstack.env import
|
|
1011
|
+
from envstack.env import Env, load_environ
|
|
1044
1012
|
|
|
1045
1013
|
# update default.env to point to test root
|
|
1046
1014
|
default_env_file = os.path.join(self.root, "prod", "env", "default.env")
|
|
@@ -1102,7 +1070,7 @@ class TestIssues(unittest.TestCase):
|
|
|
1102
1070
|
BAZ: ${BAZ}
|
|
1103
1071
|
NUM: ${NUM:=0}
|
|
1104
1072
|
"""
|
|
1105
|
-
from envstack.env import load_environ, resolve_environ
|
|
1073
|
+
from envstack.env import Source, load_environ, resolve_environ
|
|
1106
1074
|
|
|
1107
1075
|
# create grandparent.env that sets a value for FOO
|
|
1108
1076
|
grandparent = {
|
|
@@ -121,9 +121,7 @@ class TestEncryptedNode(unittest.TestCase):
|
|
|
121
121
|
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = key1
|
|
122
122
|
value = "super_secret_password"
|
|
123
123
|
encrypted = AESGCMEncryptor().encrypt(value)
|
|
124
|
-
node = EncryptedNode.from_yaml(
|
|
125
|
-
None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted)
|
|
126
|
-
)
|
|
124
|
+
node = EncryptedNode.from_yaml(None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted))
|
|
127
125
|
key2 = AESGCMEncryptor.generate_key()
|
|
128
126
|
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = key2
|
|
129
127
|
resolved = node.resolve()
|
|
@@ -142,9 +140,7 @@ class TestEncryptedNode(unittest.TestCase):
|
|
|
142
140
|
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = key
|
|
143
141
|
value = "super_secret_password"
|
|
144
142
|
encrypted = AESGCMEncryptor().encrypt(value)
|
|
145
|
-
node = EncryptedNode.from_yaml(
|
|
146
|
-
None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted)
|
|
147
|
-
)
|
|
143
|
+
node = EncryptedNode.from_yaml(None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted))
|
|
148
144
|
self.assertEqual(node.value, encrypted)
|
|
149
145
|
resolved = node.resolve()
|
|
150
146
|
self.assertEqual(resolved, value)
|
|
@@ -275,12 +271,8 @@ class TestSecretsEnv(unittest.TestCase):
|
|
|
275
271
|
def setUp(self):
|
|
276
272
|
"""set up the test environment"""
|
|
277
273
|
self.root = tempfile.mkdtemp()
|
|
278
|
-
os.environ[AESGCMEncryptor.KEY_VAR_NAME] =
|
|
279
|
-
|
|
280
|
-
)
|
|
281
|
-
os.environ[FernetEncryptor.KEY_VAR_NAME] = (
|
|
282
|
-
"v4-Ry7uKSOBEXMDv9x_crBBpi0eo2WCYNAIlSB1t4VE="
|
|
283
|
-
)
|
|
274
|
+
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = "jHLNsFrhs9JsjuPkNhYX5ubwLpId2ZSxcFXAkHyMjOU="
|
|
275
|
+
os.environ[FernetEncryptor.KEY_VAR_NAME] = "v4-Ry7uKSOBEXMDv9x_crBBpi0eo2WCYNAIlSB1t4VE="
|
|
284
276
|
|
|
285
277
|
def tearDown(self):
|
|
286
278
|
"""tear down the test environment"""
|
|
@@ -134,9 +134,7 @@ class TestMatchTemplate(unittest.TestCase):
|
|
|
134
134
|
SEQDIR="${ROOT}/projects/{seq}",
|
|
135
135
|
)
|
|
136
136
|
with patch("envstack.path._load_resolved_stack", return_value=env):
|
|
137
|
-
t = match_template(
|
|
138
|
-
"${ROOT}/projects/aa", stack="fps", scope="/tmp", expand=False
|
|
139
|
-
)
|
|
137
|
+
t = match_template("${ROOT}/projects/aa", stack="fps", scope="/tmp", expand=False)
|
|
140
138
|
self.assertIsNotNone(t)
|
|
141
139
|
self.assertEqual(str(t), "${ROOT}/projects/{seq}")
|
|
142
140
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
"""Coarse performance regression tests for envstack."""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
from helpers import create_fixture_env_root
|
|
14
|
+
|
|
15
|
+
from envstack import util
|
|
16
|
+
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
|
|
17
|
+
from envstack.env import encrypt_environ, load_environ, resolve_environ
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PerformanceTests(unittest.TestCase):
|
|
21
|
+
"""Coarse-grained performance guardrails for envstack hotspots."""
|
|
22
|
+
|
|
23
|
+
def setUp(self):
|
|
24
|
+
self.root = create_fixture_env_root()
|
|
25
|
+
self.old_env = os.environ.copy()
|
|
26
|
+
self.old_cache_timeout = util.CACHE_TIMEOUT
|
|
27
|
+
|
|
28
|
+
os.environ["ROOT"] = self.root
|
|
29
|
+
os.environ["ENVPATH"] = os.pathsep.join(
|
|
30
|
+
[
|
|
31
|
+
os.path.join(self.root, "prod", "env"),
|
|
32
|
+
os.path.join(self.root, "dev", "env"),
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = "jHLNsFrhs9JsjuPkNhYX5ubwLpId2ZSxcFXAkHyMjOU="
|
|
36
|
+
os.environ[FernetEncryptor.KEY_VAR_NAME] = "v4-Ry7uKSOBEXMDv9x_crBBpi0eo2WCYNAIlSB1t4VE="
|
|
37
|
+
|
|
38
|
+
# Keep memoized paths warm and stable across the repeated calls below.
|
|
39
|
+
util.CACHE_TIMEOUT = 60
|
|
40
|
+
|
|
41
|
+
def tearDown(self):
|
|
42
|
+
os.environ.clear()
|
|
43
|
+
os.environ.update(self.old_env)
|
|
44
|
+
util.CACHE_TIMEOUT = self.old_cache_timeout
|
|
45
|
+
shutil.rmtree(self.root)
|
|
46
|
+
|
|
47
|
+
def test_load_environ_repeated_dev_stack_stays_fast(self):
|
|
48
|
+
"""Repeated stack loading should remain comfortably below startup-budget scale."""
|
|
49
|
+
start = time.perf_counter()
|
|
50
|
+
last = None
|
|
51
|
+
for _ in range(200):
|
|
52
|
+
last = load_environ("dev", scope=self.root)
|
|
53
|
+
elapsed = time.perf_counter() - start
|
|
54
|
+
|
|
55
|
+
self.assertIsNotNone(last)
|
|
56
|
+
self.assertEqual(last["ENV"], "dev")
|
|
57
|
+
self.assertEqual(last["STACK"], "dev")
|
|
58
|
+
self.assertLess(elapsed, 1.0)
|
|
59
|
+
|
|
60
|
+
def test_resolve_environ_large_modifier_graph_stays_fast(self):
|
|
61
|
+
"""Large chained substitutions should not regress catastrophically."""
|
|
62
|
+
env = {"ROOT": self.root, "PATH": "/usr/bin", "ENVPATH": os.environ["ENVPATH"]}
|
|
63
|
+
for i in range(500):
|
|
64
|
+
env[f"VAR_{i}"] = "${ROOT}/show/pkg/%d:${PATH}" % i
|
|
65
|
+
|
|
66
|
+
start = time.perf_counter()
|
|
67
|
+
resolved = resolve_environ(env)
|
|
68
|
+
elapsed = time.perf_counter() - start
|
|
69
|
+
|
|
70
|
+
self.assertTrue(resolved["VAR_499"].startswith(self.root))
|
|
71
|
+
self.assertIn("/pkg/499", resolved["VAR_499"])
|
|
72
|
+
self.assertLess(elapsed, 1.0)
|
|
73
|
+
|
|
74
|
+
def test_encrypted_stack_load_and_resolve_stays_fast(self):
|
|
75
|
+
"""Encrypted stack resolution should stay well within a coarse CI budget."""
|
|
76
|
+
start = time.perf_counter()
|
|
77
|
+
last = None
|
|
78
|
+
for _ in range(100):
|
|
79
|
+
last = resolve_environ(load_environ("secrets", scope=self.root, encrypt=True))
|
|
80
|
+
elapsed = time.perf_counter() - start
|
|
81
|
+
|
|
82
|
+
self.assertIsNotNone(last)
|
|
83
|
+
self.assertEqual(last["KEY"], "This is encrypted")
|
|
84
|
+
self.assertEqual(last["SECRET"], "my_super_secret_password")
|
|
85
|
+
self.assertLess(elapsed, 1.5)
|
|
86
|
+
|
|
87
|
+
def test_encrypt_environ_medium_payload_stays_fast(self):
|
|
88
|
+
"""Bulk encryption of a moderate environment should remain snappy."""
|
|
89
|
+
env = load_environ("dev", scope=self.root)
|
|
90
|
+
for i in range(200):
|
|
91
|
+
env[f"EXTRA_{i}"] = "${DEPLOY_ROOT}/lib/%d:${PATH}" % i
|
|
92
|
+
|
|
93
|
+
start = time.perf_counter()
|
|
94
|
+
encrypted = encrypt_environ(env)
|
|
95
|
+
elapsed = time.perf_counter() - start
|
|
96
|
+
|
|
97
|
+
self.assertEqual(encrypted["ENV"].resolve(env=os.environ), "dev")
|
|
98
|
+
self.assertLess(elapsed, 1.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
unittest.main()
|
|
@@ -39,14 +39,14 @@ from unittest.mock import patch
|
|
|
39
39
|
|
|
40
40
|
from envstack import config, util
|
|
41
41
|
from envstack.util import (
|
|
42
|
-
null,
|
|
43
|
-
encode,
|
|
44
|
-
evaluate_command,
|
|
45
|
-
evaluate_modifiers,
|
|
46
42
|
dedupe_list,
|
|
47
43
|
dedupe_paths,
|
|
48
44
|
detect_path,
|
|
45
|
+
encode,
|
|
46
|
+
evaluate_command,
|
|
47
|
+
evaluate_modifiers,
|
|
49
48
|
get_stack_name,
|
|
49
|
+
null,
|
|
50
50
|
partition_platform_data,
|
|
51
51
|
safe_eval,
|
|
52
52
|
split_paths,
|
|
@@ -443,6 +443,7 @@ darwin:
|
|
|
443
443
|
'<<': '*all'
|
|
444
444
|
"""
|
|
445
445
|
import tempfile
|
|
446
|
+
|
|
446
447
|
from envstack.util import unquote_strings
|
|
447
448
|
|
|
448
449
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
|
@@ -586,9 +587,7 @@ class TestDedupePaths(unittest.TestCase):
|
|
|
586
587
|
"/some/other/path",
|
|
587
588
|
]
|
|
588
589
|
result = dedupe_paths(":".join(paths))
|
|
589
|
-
expected_result = os.pathsep.join(
|
|
590
|
-
["/usr/bin", "/usr/local/bin", "/some/other/path"]
|
|
591
|
-
)
|
|
590
|
+
expected_result = os.pathsep.join(["/usr/bin", "/usr/local/bin", "/some/other/path"])
|
|
592
591
|
self.assertEqual(result, expected_result)
|
|
593
592
|
|
|
594
593
|
paths = ["/usr/bin"]
|
|
@@ -624,9 +623,7 @@ class TestDedupePaths(unittest.TestCase):
|
|
|
624
623
|
]
|
|
625
624
|
path = ":".join(paths)
|
|
626
625
|
result = dedupe_paths(path, platform="windows")
|
|
627
|
-
self.assertEqual(
|
|
628
|
-
result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin"
|
|
629
|
-
)
|
|
626
|
+
self.assertEqual(result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin")
|
|
630
627
|
|
|
631
628
|
# mixed paths
|
|
632
629
|
path = "//tools/pipe/prod/env;//tools/pipe/prod/env;/home/user/envstack/env"
|
|
@@ -634,13 +631,9 @@ class TestDedupePaths(unittest.TestCase):
|
|
|
634
631
|
self.assertEqual(result, "//tools/pipe/prod/env;/home/user/envstack/env")
|
|
635
632
|
|
|
636
633
|
# mixed paths with duplicate
|
|
637
|
-
path =
|
|
638
|
-
"C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin:/usr/local/bin"
|
|
639
|
-
)
|
|
634
|
+
path = "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin:/usr/local/bin"
|
|
640
635
|
result = dedupe_paths(path, platform="windows")
|
|
641
|
-
self.assertEqual(
|
|
642
|
-
result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin"
|
|
643
|
-
)
|
|
636
|
+
self.assertEqual(result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin")
|
|
644
637
|
|
|
645
638
|
|
|
646
639
|
class TestSafeEval(unittest.TestCase):
|
|
@@ -35,12 +35,12 @@ Contains unit tests for the wrapper.py module.
|
|
|
35
35
|
|
|
36
36
|
import os
|
|
37
37
|
import sys
|
|
38
|
-
import pytest
|
|
39
38
|
from types import SimpleNamespace
|
|
40
39
|
|
|
41
|
-
import
|
|
42
|
-
from envstack.wrapper import Wrapper, CommandWrapper, run_command, capture_output
|
|
40
|
+
import pytest
|
|
43
41
|
|
|
42
|
+
import envstack.wrapper as wrapper_mod
|
|
43
|
+
from envstack.wrapper import CommandWrapper, Wrapper, capture_output, run_command
|
|
44
44
|
|
|
45
45
|
IS_WINDOWS = sys.platform.startswith("win")
|
|
46
46
|
|
|
@@ -78,9 +78,7 @@ def test_wrapper_shell_true_allows_command_string(stub_env, capfd):
|
|
|
78
78
|
rc = w.launch()
|
|
79
79
|
out, err = capfd.readouterr()
|
|
80
80
|
assert rc == 0
|
|
81
|
-
assert out.strip() == (
|
|
82
|
-
"C:\\tmp\\envstack-root" if IS_WINDOWS else "/tmp/envstack-root"
|
|
83
|
-
)
|
|
81
|
+
assert out.strip() == ("C:\\tmp\\envstack-root" if IS_WINDOWS else "/tmp/envstack-root")
|
|
84
82
|
|
|
85
83
|
|
|
86
84
|
def test_commandwrapper_runs_argv_without_shell(stub_env, capfd):
|
envstack-1.0.3/dist.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"author": "ryan@rsg.io",
|
|
3
|
-
"targets": {
|
|
4
|
-
"env": {
|
|
5
|
-
"source": "env/*",
|
|
6
|
-
"destination": "{DEPLOY_ROOT}/env/%1"
|
|
7
|
-
},
|
|
8
|
-
"bin": {
|
|
9
|
-
"source": "bin/*",
|
|
10
|
-
"destination": "{DEPLOY_ROOT}/bin/%1"
|
|
11
|
-
},
|
|
12
|
-
"bash_completion": {
|
|
13
|
-
"source": "bin/envstack_completion.sh",
|
|
14
|
-
"destination": "/etc/bash_completion.d/envstack"
|
|
15
|
-
},
|
|
16
|
-
"lib": {
|
|
17
|
-
"source": "lib/envstack",
|
|
18
|
-
"destination": "{DEPLOY_ROOT}/lib/python/envstack"
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|