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.
Files changed (33) hide show
  1. {envstack-1.0.3/lib/envstack.egg-info → envstack-1.0.4}/PKG-INFO +11 -1
  2. {envstack-1.0.3 → envstack-1.0.4}/README.md +4 -0
  3. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/__init__.py +2 -3
  4. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/cli.py +4 -8
  5. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/encrypt.py +9 -3
  6. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/env.py +3 -12
  7. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/node.py +1 -3
  8. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/path.py +3 -9
  9. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/util.py +9 -33
  10. envstack-1.0.4/lib/envstack/wrapper.py +333 -0
  11. {envstack-1.0.3 → envstack-1.0.4/lib/envstack.egg-info}/PKG-INFO +11 -1
  12. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/SOURCES.txt +1 -1
  13. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/requires.txt +7 -0
  14. {envstack-1.0.3 → envstack-1.0.4}/pyproject.toml +26 -1
  15. {envstack-1.0.3 → envstack-1.0.4}/tests/test_cmds.py +7 -12
  16. {envstack-1.0.3 → envstack-1.0.4}/tests/test_encrypt.py +1 -6
  17. {envstack-1.0.3 → envstack-1.0.4}/tests/test_env.py +10 -42
  18. {envstack-1.0.3 → envstack-1.0.4}/tests/test_node.py +4 -12
  19. {envstack-1.0.3 → envstack-1.0.4}/tests/test_path.py +1 -3
  20. envstack-1.0.4/tests/test_performance.py +102 -0
  21. {envstack-1.0.3 → envstack-1.0.4}/tests/test_util.py +9 -16
  22. {envstack-1.0.3 → envstack-1.0.4}/tests/test_wrapper.py +4 -6
  23. envstack-1.0.3/dist.json +0 -21
  24. {envstack-1.0.3 → envstack-1.0.4}/LICENSE +0 -0
  25. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/config.py +0 -0
  26. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/envshell.py +0 -0
  27. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/exceptions.py +0 -0
  28. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack/logger.py +0 -0
  29. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/dependency_links.txt +0 -0
  30. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/entry_points.txt +0 -0
  31. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/not-zip-safe +0 -0
  32. {envstack-1.0.3 → envstack-1.0.4}/lib/envstack.egg-info/top_level.txt +0 -0
  33. {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
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,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="docs/assets/envstack.png" alt="envstack logo" width="560">
3
+ </p>
4
+
1
5
  envstack
2
6
  ========
3
7
 
@@ -34,7 +34,6 @@ Stacked environment variable management system.
34
34
  """
35
35
 
36
36
  __prog__ = "envstack"
37
- __version__ = "1.0.3"
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(algorithms.AES(self.key), modes.GCM(nonce, tag))
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
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
@@ -5,3 +5,10 @@ cryptography<47,>=43.0.1
5
5
 
6
6
  [:python_version >= "3.9"]
7
7
  cryptography>=43.0.1
8
+
9
+ [dev]
10
+ pytest
11
+ flake8==7.1.1
12
+ mccabe==0.7.0
13
+ isort==5.13.2
14
+ black==24.8.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "envstack"
7
- version = "1.0.3"
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 = f"""goodbye\n"""
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
- import tempfile
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"], f"/var/tmp/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 load_environ, encrypt_environ
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 load_environ, Env
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, Source
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
- "jHLNsFrhs9JsjuPkNhYX5ubwLpId2ZSxcFXAkHyMjOU="
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 envstack.wrapper as wrapper_mod
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