envstack 1.0.2__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.2/lib/envstack.egg-info → envstack-1.0.4}/PKG-INFO +16 -2
- {envstack-1.0.2 → envstack-1.0.4}/README.md +4 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/__init__.py +2 -3
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/cli.py +4 -8
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/encrypt.py +22 -6
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/env.py +4 -13
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/node.py +1 -3
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/path.py +3 -9
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/util.py +9 -33
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/wrapper.py +2 -4
- {envstack-1.0.2 → envstack-1.0.4/lib/envstack.egg-info}/PKG-INFO +16 -2
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack.egg-info/SOURCES.txt +1 -1
- envstack-1.0.4/lib/envstack.egg-info/requires.txt +14 -0
- {envstack-1.0.2 → envstack-1.0.4}/pyproject.toml +31 -2
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_cmds.py +7 -12
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_encrypt.py +1 -6
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_env.py +10 -42
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_node.py +4 -12
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_path.py +1 -3
- envstack-1.0.4/tests/test_performance.py +102 -0
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_util.py +9 -16
- {envstack-1.0.2 → envstack-1.0.4}/tests/test_wrapper.py +4 -6
- envstack-1.0.2/dist.json +0 -21
- envstack-1.0.2/lib/envstack.egg-info/requires.txt +0 -2
- {envstack-1.0.2 → envstack-1.0.4}/LICENSE +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/config.py +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/envshell.py +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/exceptions.py +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack/logger.py +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack.egg-info/dependency_links.txt +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack.egg-info/entry_points.txt +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack.egg-info/not-zip-safe +0 -0
- {envstack-1.0.2 → envstack-1.0.4}/lib/envstack.egg-info/top_level.txt +0 -0
- {envstack-1.0.2 → 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
|
|
@@ -20,11 +20,25 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.9
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
26
|
Requires-Python: >=3.6
|
|
24
27
|
Description-Content-Type: text/markdown
|
|
25
28
|
License-File: LICENSE
|
|
26
29
|
Requires-Dist: PyYAML>=5.1.2
|
|
27
|
-
Requires-Dist: cryptography
|
|
30
|
+
Requires-Dist: cryptography<47,>=43.0.1; python_version < "3.9"
|
|
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>
|
|
28
42
|
|
|
29
43
|
envstack
|
|
30
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
|
|
|
@@ -37,6 +37,7 @@ import base64
|
|
|
37
37
|
import binascii
|
|
38
38
|
import os
|
|
39
39
|
import secrets
|
|
40
|
+
import warnings
|
|
40
41
|
from base64 import b64decode, b64encode
|
|
41
42
|
from functools import wraps
|
|
42
43
|
|
|
@@ -52,11 +53,22 @@ padding = None
|
|
|
52
53
|
Cipher = None
|
|
53
54
|
algorithms = None
|
|
54
55
|
modes = None
|
|
56
|
+
default_backend = None
|
|
55
57
|
try:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
# cryptography emits a Python 3.8 deprecation warning during import.
|
|
59
|
+
with warnings.catch_warnings():
|
|
60
|
+
warnings.filterwarnings(
|
|
61
|
+
"ignore",
|
|
62
|
+
message=r"Python 3\.8 is no longer supported by the Python core team.*",
|
|
63
|
+
category=DeprecationWarning,
|
|
64
|
+
module=r"cryptography(\..*)?$",
|
|
65
|
+
)
|
|
66
|
+
from cryptography.exceptions import InvalidTag
|
|
67
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
68
|
+
from cryptography.hazmat.backends import default_backend
|
|
69
|
+
from cryptography.hazmat.primitives import padding
|
|
70
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
71
|
+
|
|
60
72
|
CRYPTOGRAPHY_AVAILABLE = True
|
|
61
73
|
except ImportError as err:
|
|
62
74
|
log.debug("cryptography module not available: %s", err)
|
|
@@ -204,7 +216,7 @@ class AESGCMEncryptor(object):
|
|
|
204
216
|
:return: Dictionary containing nonce, ciphertext, and tag.
|
|
205
217
|
"""
|
|
206
218
|
nonce = os.urandom(12) # GCM requires a 12-byte nonce
|
|
207
|
-
cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce))
|
|
219
|
+
cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce), backend=default_backend())
|
|
208
220
|
encryptor = cipher.encryptor()
|
|
209
221
|
padded_data = pad_data(secret)
|
|
210
222
|
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
|
@@ -225,7 +237,11 @@ class AESGCMEncryptor(object):
|
|
|
225
237
|
nonce = b64decode(encrypted_data["nonce"])
|
|
226
238
|
ciphertext = b64decode(encrypted_data["ciphertext"])
|
|
227
239
|
tag = b64decode(encrypted_data["tag"])
|
|
228
|
-
cipher = Cipher(
|
|
240
|
+
cipher = Cipher(
|
|
241
|
+
algorithms.AES(self.key),
|
|
242
|
+
modes.GCM(nonce, tag),
|
|
243
|
+
backend=default_backend(),
|
|
244
|
+
)
|
|
229
245
|
decryptor = cipher.decryptor()
|
|
230
246
|
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
|
|
231
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("(?![^{]*})[;:]+")
|
|
@@ -248,7 +243,7 @@ class EnvVar(string.Template, str):
|
|
|
248
243
|
>>> v.vars()
|
|
249
244
|
['FOO', 'BAR']
|
|
250
245
|
"""
|
|
251
|
-
matches =
|
|
246
|
+
matches = type(self).pattern.findall(str(self.template))
|
|
252
247
|
return [key for match in matches for key in match if key]
|
|
253
248
|
|
|
254
249
|
|
|
@@ -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
|
|
|
@@ -286,15 +286,13 @@ def capture_output(
|
|
|
286
286
|
except FileNotFoundError as e:
|
|
287
287
|
# no process ran; synthesize a bash-like error and code
|
|
288
288
|
# 127 is the conventional "command not found" code in shells
|
|
289
|
-
missing = e.filename or (
|
|
290
|
-
cmd[0] if isinstance(cmd, list) and cmd else str(command)
|
|
291
|
-
)
|
|
289
|
+
missing = e.filename or (cmd[0] if isinstance(cmd, list) and cmd else str(command))
|
|
292
290
|
return 127, "", f"{missing}: command not found"
|
|
293
291
|
except OSError as e:
|
|
294
292
|
# Other OS-level execution errors (permission, exec format, etc.)
|
|
295
293
|
rc = 126 if getattr(e, "errno", None) in (errno.EACCES,) else 1
|
|
296
294
|
return rc, "", str(e)
|
|
297
|
-
except subprocess.TimeoutExpired
|
|
295
|
+
except subprocess.TimeoutExpired:
|
|
298
296
|
return 124, "", f"Command timed out after {timeout} seconds"
|
|
299
297
|
|
|
300
298
|
|
|
@@ -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
|
|
@@ -20,11 +20,25 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.9
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
26
|
Requires-Python: >=3.6
|
|
24
27
|
Description-Content-Type: text/markdown
|
|
25
28
|
License-File: LICENSE
|
|
26
29
|
Requires-Dist: PyYAML>=5.1.2
|
|
27
|
-
Requires-Dist: cryptography
|
|
30
|
+
Requires-Dist: cryptography<47,>=43.0.1; python_version < "3.9"
|
|
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>
|
|
28
42
|
|
|
29
43
|
envstack
|
|
30
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"
|
|
@@ -12,7 +12,8 @@ license = { text = "BSD 3-Clause License" }
|
|
|
12
12
|
authors = [{ name = "Ryan Galloway", email = "ryan@rsgalloway.com" }]
|
|
13
13
|
dependencies = [
|
|
14
14
|
"PyYAML>=5.1.2",
|
|
15
|
-
|
|
15
|
+
'cryptography>=43.0.1,<47; python_version < "3.9"',
|
|
16
|
+
'cryptography>=43.0.1; python_version >= "3.9"',
|
|
16
17
|
]
|
|
17
18
|
classifiers = [
|
|
18
19
|
"Development Status :: 4 - Beta",
|
|
@@ -25,6 +26,9 @@ classifiers = [
|
|
|
25
26
|
"Programming Language :: Python :: 3.9",
|
|
26
27
|
"Programming Language :: Python :: 3.10",
|
|
27
28
|
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
28
32
|
]
|
|
29
33
|
keywords = [
|
|
30
34
|
"environment",
|
|
@@ -52,8 +56,33 @@ Documentation = "https://github.com/rsgalloway/envstack/tree/master/docs"
|
|
|
52
56
|
envstack = "envstack.cli:main"
|
|
53
57
|
whichenv = "envstack.cli:whichenv"
|
|
54
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
|
+
|
|
55
68
|
[tool.setuptools]
|
|
56
69
|
zip-safe = false
|
|
57
70
|
|
|
58
71
|
[tool.setuptools.packages.find]
|
|
59
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.2/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
|