envstack 0.9.3__tar.gz → 0.9.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 (31) hide show
  1. {envstack-0.9.3/lib/envstack.egg-info → envstack-0.9.4}/PKG-INFO +2 -1
  2. {envstack-0.9.3 → envstack-0.9.4}/README.md +1 -0
  3. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/__init__.py +2 -2
  4. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/cli.py +14 -13
  5. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/config.py +1 -1
  6. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/encrypt.py +1 -1
  7. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/env.py +1 -1
  8. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/envshell.py +20 -1
  9. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/exceptions.py +1 -1
  10. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/logger.py +1 -1
  11. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/node.py +1 -1
  12. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/path.py +1 -1
  13. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/util.py +1 -1
  14. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack/wrapper.py +62 -57
  15. {envstack-0.9.3 → envstack-0.9.4/lib/envstack.egg-info}/PKG-INFO +2 -1
  16. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack.egg-info/SOURCES.txt +2 -1
  17. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack.egg-info/entry_points.txt +0 -1
  18. {envstack-0.9.3 → envstack-0.9.4}/setup.py +2 -3
  19. {envstack-0.9.3 → envstack-0.9.4}/tests/test_cmds.py +23 -70
  20. {envstack-0.9.3 → envstack-0.9.4}/tests/test_encrypt.py +1 -1
  21. {envstack-0.9.3 → envstack-0.9.4}/tests/test_env.py +1 -1
  22. {envstack-0.9.3 → envstack-0.9.4}/tests/test_node.py +1 -1
  23. {envstack-0.9.3 → envstack-0.9.4}/tests/test_util.py +1 -1
  24. envstack-0.9.4/tests/test_wrapper.py +141 -0
  25. {envstack-0.9.3 → envstack-0.9.4}/LICENSE +0 -0
  26. {envstack-0.9.3 → envstack-0.9.4}/dist.json +0 -0
  27. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack.egg-info/dependency_links.txt +0 -0
  28. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack.egg-info/not-zip-safe +0 -0
  29. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack.egg-info/requires.txt +0 -0
  30. {envstack-0.9.3 → envstack-0.9.4}/lib/envstack.egg-info/top_level.txt +0 -0
  31. {envstack-0.9.3 → envstack-0.9.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: envstack
3
- Version: 0.9.3
3
+ Version: 0.9.4
4
4
  Summary: Stacked environment variable management system
5
5
  Home-page: http://github.com/rsgalloway/envstack
6
6
  Author: Ryan Galloway
@@ -616,6 +616,7 @@ The following environment variables are used to help manage functionality:
616
616
  | DEFAULT_ENV_STACK | Name of the default environment stack (default) |
617
617
  | ENVPATH | Colon-separated paths to search for stack files |
618
618
  | IGNORE_MISSING | Ignore missing stack files when resolving environments |
619
+ | INTERACTIVE | Run one-off commands in an interactive shell |
619
620
  | STACK | Stores the name of the current environment stack |
620
621
 
621
622
  # Tests
@@ -592,6 +592,7 @@ The following environment variables are used to help manage functionality:
592
592
  | DEFAULT_ENV_STACK | Name of the default environment stack (default) |
593
593
  | ENVPATH | Colon-separated paths to search for stack files |
594
594
  | IGNORE_MISSING | Ignore missing stack files when resolving environments |
595
+ | INTERACTIVE | Run one-off commands in an interactive shell |
595
596
  | STACK | Stores the name of the current environment stack |
596
597
 
597
598
  # Tests
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -34,6 +34,6 @@ Stacked environment variable management system.
34
34
  """
35
35
 
36
36
  __prog__ = "envstack"
37
- __version__ = "0.9.3"
37
+ __version__ = "0.9.4"
38
38
 
39
39
  from envstack.env import clear, init, revert, save # noqa: F401
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -161,11 +161,6 @@ def parse_args():
161
161
  action="store_true",
162
162
  help="create a bare environment",
163
163
  )
164
- parser.add_argument(
165
- "--shell",
166
- action="store_true",
167
- help="drop into a shell with the environment loaded",
168
- )
169
164
  encrypt_group = parser.add_argument_group("encryption options")
170
165
  encrypt_group.add_argument(
171
166
  "-e",
@@ -226,6 +221,12 @@ def parse_args():
226
221
  metavar="SCOPE",
227
222
  help="search scope for environment stack files",
228
223
  )
224
+ parser.add_argument(
225
+ "-u",
226
+ "--unresolved",
227
+ action="store_true",
228
+ help="dump unresolved environment variables to stdout",
229
+ )
229
230
  parser.add_argument(
230
231
  "-r",
231
232
  "--resolve",
@@ -263,11 +264,11 @@ def envshell(namespace: List[str] = None):
263
264
  """Run a shell in the given environment stack."""
264
265
  from .envshell import EnvshellWrapper
265
266
 
266
- print("\U0001F680 Launching envshell... CTRL+D to exit")
267
+ print("\U0001F680 Launching envstack shell... CTRL+D to exit")
267
268
 
268
269
  name = (namespace or [config.DEFAULT_NAMESPACE])[:]
269
- envshell = EnvshellWrapper(name)
270
- return envshell.launch()
270
+ shell = EnvshellWrapper(name)
271
+ return shell.launch()
271
272
 
272
273
 
273
274
  def whichenv():
@@ -292,9 +293,6 @@ def main():
292
293
  if command:
293
294
  return run_command(command, args.namespace)
294
295
 
295
- elif args.shell:
296
- return envshell(args.namespace)
297
-
298
296
  elif args.keygen:
299
297
  from envstack.encrypt import generate_keys
300
298
 
@@ -445,13 +443,16 @@ def main():
445
443
  for source in env.sources:
446
444
  print(source.path)
447
445
 
448
- else:
446
+ elif args.unresolved:
449
447
  env = load_environ(
450
448
  args.namespace, platform=args.platform, encrypt=args.encrypt
451
449
  )
452
450
  for k, v in sorted(env.items(), key=lambda x: str(x[0])):
453
451
  print(f"{k}={v}")
454
452
 
453
+ else:
454
+ return envshell(args.namespace)
455
+
455
456
  except KeyboardInterrupt:
456
457
  print("Stopping...")
457
458
  return 2
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -122,3 +122,22 @@ class EnvshellWrapper(Wrapper):
122
122
  Override to return argv list for subprocess.Popen(..., shell=False).
123
123
  """
124
124
  return _detect_shell_argv()
125
+
126
+ def get_shell_prompt(self) -> str:
127
+ """
128
+ Return the environment variable that controls the shell prompt and its
129
+ desired value.
130
+ """
131
+ if os.name == "nt":
132
+ return ("PROMPT", "$E[32m(${ENV:=${STACK}})$E[0m $P$G ")
133
+ else:
134
+ return ("PS1", "\[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$ ")
135
+
136
+ def get_subprocess_env(self):
137
+ """
138
+ Override to inject PS1/PROMPT if not already set.
139
+ """
140
+ prompt_env, prompt_value = self.get_shell_prompt()
141
+ if prompt_env not in self.env:
142
+ self.env[prompt_env] = prompt_value
143
+ return super().get_subprocess_env()
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -37,7 +37,6 @@ import os
37
37
  import re
38
38
  import shlex
39
39
  import subprocess
40
- import sys
41
40
  import traceback
42
41
 
43
42
  from envstack import config, logger
@@ -93,6 +92,7 @@ class Wrapper(object):
93
92
 
94
93
  tool.log = MyLogger()
95
94
  """
95
+ shell: bool = False
96
96
 
97
97
  def __init__(self, namespace, args=[]):
98
98
  """Initializes the wrapper with the given namespace and args.
@@ -103,7 +103,7 @@ class Wrapper(object):
103
103
  super(Wrapper, self).__init__()
104
104
  self.args = args
105
105
  self.name = namespace
106
- self.shell = True
106
+ self.shell = self.shell
107
107
  self.log = logger.log
108
108
  self.env = load_environ(namespace)
109
109
 
@@ -113,38 +113,34 @@ class Wrapper(object):
113
113
 
114
114
  def launch(self):
115
115
  """Launches the wrapped tool in a subprocess with env."""
116
- exitcode = 0
117
116
  env = self.get_subprocess_env()
118
117
  command = self.get_subprocess_command(env)
119
118
 
120
119
  try:
121
- process = subprocess.Popen(
122
- args=command,
123
- bufsize=0,
120
+ proc = subprocess.run(
121
+ command,
124
122
  env=encode(env),
125
123
  shell=self.shell,
124
+ check=False,
126
125
  )
126
+ return proc.returncode
127
127
  except Exception:
128
128
  traceback.print_exc()
129
- exitcode = 1
130
- else:
131
- stdout, stderr = process.communicate() # dead code, kept for reference
132
- while stdout and stderr:
133
- self.log.info(stdout)
134
- self.log.error(stderr)
135
- exitcode = process.poll()
136
-
137
- return exitcode
129
+ return 1
138
130
 
139
131
  def get_subprocess_args(self, cmd):
140
132
  """Returns the arguments to be passed to the subprocess."""
141
133
  return self.args
142
134
 
143
135
  def get_subprocess_command(self, env):
144
- """Returns the command to be passed to the subprocess."""
136
+ """Returns argv (preferred) or a command string if shell=True."""
145
137
  cmd = evaluate_modifiers(self.executable(), env)
146
138
  args = self.get_subprocess_args(cmd)
147
- return " ".join([cmd] + args)
139
+
140
+ if self.shell:
141
+ return " ".join([cmd] + args)
142
+
143
+ return [cmd] + args
148
144
 
149
145
  def get_subprocess_env(self):
150
146
  """
@@ -173,52 +169,39 @@ class CommandWrapper(Wrapper):
173
169
  :param args: command and arguments as a list.
174
170
  """
175
171
  super(CommandWrapper, self).__init__(namespace, args)
176
- self.cmd = args
172
+ self.cmd = list(args)
177
173
 
178
174
  def executable(self):
179
175
  """Returns the command to run."""
180
- return config.SHELL
176
+ return self.cmd[0]
181
177
 
178
+ def get_subprocess_args(self, cmd):
179
+ return self.cmd[1:]
182
180
 
183
- class ShellWrapper(CommandWrapper):
184
- """Wrapper class for running wrapped commands in bash, sh, or zsh."""
185
181
 
186
- def __init__(self, namespace=config.DEFAULT_NAMESPACE, args=[]):
187
- """
188
- Initializes the command wrapper with the given namespace and args,
189
- replacing the original command with the shell command, e.g.:
182
+ class ShellWrapper(Wrapper):
183
+ """
184
+ Runs a *string expression* under the user's shell using -c.
185
+ Useful for wrapping shell expressions that need variable expansion:
190
186
 
191
- >>> cmd = ShellWrapper(stack, ['ls', '-l'])
192
- >>> print(cmd.executable())
193
- bash
194
- >>> print(cmd.args)
195
- ['-i', '-c', 'ls -l']
187
+ >>> shell = ShellWrapper('default', 'echo {HOME}')
188
+ """
196
189
 
197
- :param namespace: environment stack name (default: 'default').
198
- :param args: command and arguments as a list.
199
- :param interactive: run the command in an interactive shell (default: True).
200
- """
201
- super(ShellWrapper, self).__init__(namespace, args)
190
+ def __init__(self, namespace=config.DEFAULT_NAMESPACE, expr: str = ""):
191
+ super().__init__(namespace, args=[])
192
+ self.expr = expr
202
193
 
203
194
  def get_interactive(self, env: dict = os.environ):
204
- """Returns whether to run the command in an interactive shell."""
205
195
  override = env.get("INTERACTIVE")
206
196
  if override is not None:
207
- return override.lower() in {"1", "true", "yes", "on"}
208
- return sys.stdin.isatty() and sys.stdout.isatty()
197
+ return str(override).lower() in {"1", "true", "yes", "on"}
198
+ return False # default safe
209
199
 
210
200
  def get_subprocess_command(self, env: dict = os.environ):
211
- """Returns the command to be passed to the shell in a subprocess."""
212
201
  interactive = self.get_interactive(env)
213
- if re.search(r"\$\w+", self.cmd):
214
- if interactive:
215
- return f'{config.SHELL} -i -c "{self.cmd}"'
216
- return f'{config.SHELL} -c "{self.cmd}"'
217
- else:
218
- escaped_command = shlex.quote(self.cmd)
219
- if interactive:
220
- return f"{config.SHELL} -i -c {escaped_command}"
221
- return f"{config.SHELL} -c {escaped_command}"
202
+ if interactive:
203
+ return [config.SHELL, "-i", "-c", self.expr]
204
+ return [config.SHELL, "-c", self.expr]
222
205
 
223
206
  def executable(self):
224
207
  """Returns the shell command to run the original command."""
@@ -276,12 +259,34 @@ def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):
276
259
  """
277
260
  logger.setup_stream_handler()
278
261
  shellname = os.path.basename(config.SHELL)
262
+
263
+ # normalize to argv list
264
+ argv = list(command) if isinstance(command, (list, tuple)) else to_args(command)
265
+
266
+ needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
267
+ if needs_shell:
268
+ expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
269
+ expr = shell_join(expr_argv)
270
+ return ShellWrapper(namespace, expr).launch()
271
+
279
272
  if shellname in ["bash", "sh", "zsh"]:
280
- command = re.sub(r"\{(\w+)\}", r"${\1}", shell_join(command))
281
- cmd = ShellWrapper(namespace, command)
282
- elif shellname in ["cmd"]:
283
- command = re.sub(r"\{(\w+)\}", r"%\1%", " ".join(command))
284
- cmd = CmdWrapper(namespace, command)
285
- else:
286
- cmd = CommandWrapper(namespace, command)
287
- return cmd.launch()
273
+ # 1) if user explicitly invoked a shell (bash/sh/zsh), do not wrap again
274
+ if argv and os.path.basename(argv[0]) in ["bash", "sh", "zsh"]:
275
+ return CommandWrapper(namespace, argv).launch()
276
+
277
+ # 2) if command contains {VARS}, convert to ${VARS} and run as a shell expression
278
+ needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
279
+ if needs_shell:
280
+ expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
281
+ expr = shell_join(expr_argv)
282
+ return ShellWrapper(namespace, expr).launch()
283
+
284
+ # 3) otherwise run direct argv (best behavior)
285
+ return CommandWrapper(namespace, argv).launch()
286
+
287
+ if shellname in ["cmd"]:
288
+ # windows behavior preserved (if you need it)
289
+ expr = re.sub(r"\{(\w+)\}", r"%\1%", " ".join(argv))
290
+ return CmdWrapper(namespace, expr).launch()
291
+
292
+ return CommandWrapper(namespace, argv).launch()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: envstack
3
- Version: 0.9.3
3
+ Version: 0.9.4
4
4
  Summary: Stacked environment variable management system
5
5
  Home-page: http://github.com/rsgalloway/envstack
6
6
  Author: Ryan Galloway
@@ -616,6 +616,7 @@ The following environment variables are used to help manage functionality:
616
616
  | DEFAULT_ENV_STACK | Name of the default environment stack (default) |
617
617
  | ENVPATH | Colon-separated paths to search for stack files |
618
618
  | IGNORE_MISSING | Ignore missing stack files when resolving environments |
619
+ | INTERACTIVE | Run one-off commands in an interactive shell |
619
620
  | STACK | Stores the name of the current environment stack |
620
621
 
621
622
  # Tests
@@ -25,4 +25,5 @@ tests/test_cmds.py
25
25
  tests/test_encrypt.py
26
26
  tests/test_env.py
27
27
  tests/test_node.py
28
- tests/test_util.py
28
+ tests/test_util.py
29
+ tests/test_wrapper.py
@@ -1,4 +1,3 @@
1
1
  [console_scripts]
2
- envshell = envstack.cli:envshell
3
2
  envstack = envstack.cli:main
4
3
  whichenv = envstack.cli:whichenv
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -40,7 +40,7 @@ with open(os.path.join(here, "README.md")) as f:
40
40
 
41
41
  setup(
42
42
  name="envstack",
43
- version="0.9.3",
43
+ version="0.9.4",
44
44
  description="Stacked environment variable management system",
45
45
  long_description=long_description,
46
46
  long_description_content_type="text/markdown",
@@ -69,7 +69,6 @@ setup(
69
69
  entry_points={
70
70
  "console_scripts": [
71
71
  "envstack=envstack.cli:main",
72
- "envshell=envstack.cli:envshell",
73
72
  "whichenv=envstack.cli:whichenv",
74
73
  ],
75
74
  },
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -67,9 +67,7 @@ class TestUnresolved(unittest.TestCase):
67
67
  """Tests unresolved environment variables from the cli."""
68
68
 
69
69
  def setUp(self):
70
- self.envstack_bin = os.path.join(
71
- os.path.dirname(__file__), "..", "bin", "envstack"
72
- )
70
+ self.envstack_bin = "envstack -u"
73
71
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
74
72
  self.root = {
75
73
  "linux": "/mnt/pipe",
@@ -86,15 +84,14 @@ ENVPATH=${DEPLOY_ROOT}/env:${ENVPATH}
86
84
  HELLO=${HELLO:=world}
87
85
  LOG_LEVEL=${LOG_LEVEL:=INFO}
88
86
  PATH=${DEPLOY_ROOT}/bin:${PATH}
89
- PS1=\[\e[32m\](${ENV})\[\e[0m\] \w\$
87
+ PS1=\[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$
90
88
  PYTHONPATH=${DEPLOY_ROOT}/lib/python:${PYTHONPATH}
91
89
  ROOT=%s
92
90
  STACK=default
93
91
  """
94
92
  % self.root
95
93
  )
96
- command = self.envstack_bin
97
- output = subprocess.check_output(command, shell=True, universal_newlines=True)
94
+ output = subprocess.check_output(self.envstack_bin, shell=True, universal_newlines=True)
98
95
  self.assertEqual(output, expected_output)
99
96
 
100
97
  def test_dev(self):
@@ -105,7 +102,7 @@ ENVPATH=${ROOT}/dev/env:${ROOT}/prod/env:${ENVPATH}
105
102
  HELLO=${HELLO:=world}
106
103
  LOG_LEVEL=DEBUG
107
104
  PATH=${ROOT}/dev/bin:${ROOT}/prod/bin:${PATH}
108
- PS1=\[\e[32m\](${ENV})\[\e[0m\] \w\$
105
+ PS1=\[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$
109
106
  PYTHONPATH=${ROOT}/dev/lib/python:${ROOT}/prod/lib/python:${PYTHONPATH}
110
107
  ROOT=%s
111
108
  STACK=dev
@@ -116,25 +113,6 @@ STACK=dev
116
113
  output = subprocess.check_output(command, shell=True, universal_newlines=True)
117
114
  self.assertEqual(output, expected_output)
118
115
 
119
- def test_distman(self):
120
- expected_output = (
121
- """DEPLOY_ROOT=${ROOT}/${ENV}
122
- ENV=${ENV:=prod}
123
- ENVPATH=${DEPLOY_ROOT}/env:${ENVPATH}
124
- HELLO=${HELLO:=world}
125
- LOG_LEVEL=INFO
126
- PATH=${DEPLOY_ROOT}/bin:${PATH}
127
- PS1=\[\e[32m\](${ENV})\[\e[0m\] \w\$
128
- PYTHONPATH=${DEPLOY_ROOT}/lib/python:${PYTHONPATH}
129
- ROOT=%s
130
- STACK=distman
131
- """
132
- % self.root
133
- )
134
- command = "%s distman" % self.envstack_bin
135
- output = subprocess.check_output(command, shell=True, universal_newlines=True)
136
- self.assertEqual(output, expected_output)
137
-
138
116
  def test_hello(self):
139
117
  expected_output = (
140
118
  """DEPLOY_ROOT=${ROOT}/dev
@@ -167,7 +145,7 @@ INT=5
167
145
  LOG_LEVEL=${LOG_LEVEL:=INFO}
168
146
  NUMBER_LIST=[1, 2, 3]
169
147
  PATH=${DEPLOY_ROOT}/bin:${PATH}
170
- PS1=\[\e[32m\](${ENV})\[\e[0m\] \w\$
148
+ PS1=\[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$
171
149
  PYTHONPATH=${DEPLOY_ROOT}/lib/python:${PYTHONPATH}
172
150
  ROOT=${HOME}/.local/pipe
173
151
  STACK=thing
@@ -181,9 +159,7 @@ class TestEncrypt(unittest.TestCase):
181
159
  """Tests encrypting environment variables from the cli."""
182
160
 
183
161
  def setUp(self):
184
- self.envstack_bin = os.path.join(
185
- os.path.dirname(__file__), "..", "bin", "envstack"
186
- )
162
+ self.envstack_bin = "envstack -u"
187
163
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
188
164
  self.root = {
189
165
  "linux": "/mnt/pipe",
@@ -208,7 +184,7 @@ ENVPATH=JHtERVBMT1lfUk9PVH0vZW52OiR7RU5WUEFUSH0=
208
184
  HELLO=JHtIRUxMTzo9d29ybGR9
209
185
  LOG_LEVEL=JHtMT0dfTEVWRUw6PUlORk99
210
186
  PATH=JHtERVBMT1lfUk9PVH0vYmluOiR7UEFUSH0=
211
- PS1=XFtcZVszMm1cXSgke0VOVn0pXFtcZVswbVxdIFx3XCQg
187
+ PS1=XFtcZVszMm1cXSgke0VOVjo9JHtTVEFDS319KVxbXGVbMG1cXSBcd1wkIA==
212
188
  PYTHONPATH=JHtERVBMT1lfUk9PVH0vbGliL3B5dGhvbjoke1BZVEhPTlBBVEh9
213
189
  ROOT=L21udC9waXBl
214
190
  STACK=ZGVmYXVsdA==
@@ -257,7 +233,7 @@ ENVPATH=JHtST09UfS9kZXYvZW52OiR7Uk9PVH0vcHJvZC9lbnY6JHtFTlZQQVRIfQ==
257
233
  HELLO=JHtIRUxMTzo9d29ybGR9
258
234
  LOG_LEVEL=REVCVUc=
259
235
  PATH=JHtST09UfS9kZXYvYmluOiR7Uk9PVH0vcHJvZC9iaW46JHtQQVRIfQ==
260
- PS1=XFtcZVszMm1cXSgke0VOVn0pXFtcZVswbVxdIFx3XCQg
236
+ PS1=XFtcZVszMm1cXSgke0VOVjo9JHtTVEFDS319KVxbXGVbMG1cXSBcd1wkIA==
261
237
  PYTHONPATH=JHtST09UfS9kZXYvbGliL3B5dGhvbjoke1JPT1R9L3Byb2QvbGliL3B5dGhvbjoke1BZVEhPTlBBVEh9
262
238
  ROOT=L21udC9waXBl
263
239
  STACK=ZGV2
@@ -278,9 +254,7 @@ class TestResolved(unittest.TestCase):
278
254
  """Tests resolved environment variables."""
279
255
 
280
256
  def setUp(self):
281
- self.envstack_bin = os.path.join(
282
- os.path.dirname(__file__), "..", "bin", "envstack"
283
- )
257
+ self.envstack_bin = "envstack"
284
258
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
285
259
  self.root = {
286
260
  "linux": "/mnt/pipe",
@@ -310,15 +284,6 @@ STACK=dev
310
284
  output = subprocess.check_output(command, shell=True, universal_newlines=True)
311
285
  self.assertEqual(output, expected_output)
312
286
 
313
- def test_distman(self):
314
- expected_output = f"""DEPLOY_ROOT={self.root}/{os.getenv('ENV', 'prod')}
315
- ROOT={self.root}
316
- STACK=distman
317
- """
318
- command = "%s distman -r DEPLOY_ROOT ROOT STACK" % self.envstack_bin
319
- output = subprocess.check_output(command, shell=True, universal_newlines=True)
320
- self.assertEqual(output, expected_output)
321
-
322
287
  def test_dev_distman(self):
323
288
  expected_output = f"""DEPLOY_ROOT={self.root}/dev
324
289
  ROOT={self.root}
@@ -373,9 +338,7 @@ class TestBake(unittest.TestCase):
373
338
  self.filename = "baketest.env"
374
339
  if os.path.exists(self.filename):
375
340
  os.remove(self.filename)
376
- self.envstack_bin = os.path.join(
377
- os.path.dirname(__file__), "..", "bin", "envstack"
378
- )
341
+ self.envstack_bin = "envstack"
379
342
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
380
343
  self.root = {
381
344
  "linux": "/mnt/pipe",
@@ -408,15 +371,15 @@ all: &all
408
371
  PYTHONPATH: ${DEPLOY_ROOT}/lib/python:${PYTHONPATH}
409
372
  darwin:
410
373
  <<: *all
411
- PS1: \[\e[32m\](${ENV})\[\e[0m\] \w\$
374
+ PS1: \[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$
412
375
  ROOT: /Volumes/pipe
413
376
  linux:
414
377
  <<: *all
415
- PS1: \[\e[32m\](${ENV})\[\e[0m\] \w\$
378
+ PS1: \[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$
416
379
  ROOT: /mnt/pipe
417
380
  windows:
418
381
  <<: *all
419
- PROMPT: $E[32m(${ENV})$E[0m $P$G
382
+ PROMPT: $E[32m(${ENV:=${STACK}})$E[0m $P$G
420
383
  ROOT: //tools/pipe
421
384
  """
422
385
  output = subprocess.check_output(
@@ -499,15 +462,15 @@ all: &all
499
462
  PYTHONPATH: !encrypt JHtERVBMT1lfUk9PVH0vbGliL3B5dGhvbjoke1BZVEhPTlBBVEh9
500
463
  darwin:
501
464
  <<: *all
502
- PS1: !encrypt XFtcZVszMm1cXSgke0VOVn0pXFtcZVswbVxdIFx3XCQg
465
+ PS1: !encrypt XFtcZVszMm1cXSgke0VOVjo9JHtTVEFDS319KVxbXGVbMG1cXSBcd1wkIA==
503
466
  ROOT: !encrypt L1ZvbHVtZXMvcGlwZQ==
504
467
  linux:
505
468
  <<: *all
506
- PS1: !encrypt XFtcZVszMm1cXSgke0VOVn0pXFtcZVswbVxdIFx3XCQg
469
+ PS1: !encrypt XFtcZVszMm1cXSgke0VOVjo9JHtTVEFDS319KVxbXGVbMG1cXSBcd1wkIA==
507
470
  ROOT: !encrypt L21udC9waXBl
508
471
  windows:
509
472
  <<: *all
510
- PROMPT: !encrypt JEVbMzJtKCR7RU5WfSkkRVswbSAkUCRH
473
+ PROMPT: !encrypt JEVbMzJtKCR7RU5WOj0ke1NUQUNLfX0pJEVbMG0gJFAkRw==
511
474
  ROOT: !encrypt Ly90b29scy9waXBl
512
475
  """
513
476
  output = subprocess.check_output(
@@ -575,9 +538,7 @@ class TestCommands(unittest.TestCase):
575
538
  """Tests various envstack commands."""
576
539
 
577
540
  def setUp(self):
578
- self.envstack_bin = os.path.join(
579
- os.path.dirname(__file__), "..", "bin", "envstack"
580
- )
541
+ self.envstack_bin = "envstack"
581
542
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
582
543
  self.root = {
583
544
  "linux": "/mnt/pipe",
@@ -639,9 +600,7 @@ class TestSet(unittest.TestCase):
639
600
 
640
601
  def setUp(self):
641
602
  self.filename = "settest.env"
642
- self.envstack_bin = os.path.join(
643
- os.path.dirname(__file__), "..", "bin", "envstack"
644
- )
603
+ self.envstack_bin = "envstack"
645
604
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
646
605
  self.root = {
647
606
  "linux": "/mnt/pipe",
@@ -762,9 +721,7 @@ class TestVarFlow(unittest.TestCase):
762
721
  """Tests the flow of environment variables through stacks."""
763
722
 
764
723
  def setUp(self):
765
- self.envstack_bin = os.path.join(
766
- os.path.dirname(__file__), "..", "bin", "envstack"
767
- )
724
+ self.envstack_bin = "envstack"
768
725
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
769
726
  os.environ["ENVPATH"] = envpath
770
727
 
@@ -805,9 +762,7 @@ class TestDistman(unittest.TestCase):
805
762
  """Tests value for $DEPLOY_ROOT under various environment configurations."""
806
763
 
807
764
  def setUp(self):
808
- self.envstack_bin = os.path.join(
809
- os.path.dirname(__file__), "..", "bin", "envstack"
810
- )
765
+ self.envstack_bin = "envstack"
811
766
  self.python_cmd = """python -c \"import os,envstack;envstack.init('distman');print(os.getenv('DEPLOY_ROOT'))\""""
812
767
  envpath = os.path.join(os.path.dirname(__file__), "..", "env")
813
768
  self.root = {
@@ -858,9 +813,7 @@ class TestDistman(unittest.TestCase):
858
813
  class TestIssues(unittest.TestCase):
859
814
  def setUp(self):
860
815
  self.root = create_test_root()
861
- self.envstack_bin = os.path.join(
862
- os.path.dirname(__file__), "..", "bin", "envstack"
863
- )
816
+ self.envstack_bin = "envstack"
864
817
  os.environ["ENVPATH"] = os.path.join(self.root, "prod", "env")
865
818
 
866
819
  def tearDown(self):
@@ -988,7 +941,7 @@ class TestIssues(unittest.TestCase):
988
941
  cmd = [
989
942
  "bash",
990
943
  "-lc",
991
- 'envstack -- "echo hello from envstack"',
944
+ 'envstack -- echo "hello from envstack"',
992
945
  ]
993
946
 
994
947
  proc = subprocess.Popen(
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python
1
+ #!/usr/bin/env python3
2
2
  #
3
3
  # Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com)
4
4
  #
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2024-2025, 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 unit tests for running commands.
34
+ """
35
+
36
+ import os
37
+ import pytest
38
+
39
+ import envstack.wrapper as wrapper_mod
40
+ from envstack.wrapper import Wrapper, CommandWrapper, run_command
41
+
42
+
43
+ @pytest.fixture
44
+ def stub_env(monkeypatch):
45
+ """
46
+ Make wrapper tests independent of real envstack stack files.
47
+
48
+ We monkeypatch load_environ(namespace) to return a deterministic env.
49
+ Adjust ROOT/ENV values as needed.
50
+ """
51
+ def _fake_load_environ(namespace):
52
+ # minimal env for tests; include PATH so subprocess can find tools
53
+ env = dict(os.environ)
54
+ env["ENV"] = namespace or "prod"
55
+ env["ROOT"] = "/tmp/envstack-root"
56
+ return env
57
+
58
+ monkeypatch.setattr(wrapper_mod, "load_environ", _fake_load_environ)
59
+ return _fake_load_environ
60
+
61
+
62
+ class HelloWrapper(Wrapper):
63
+ """
64
+ Example wrapper that prints an env var passed as argv[0].
65
+ Uses shell=True because executable() returns a command-line string.
66
+ """
67
+ shell = True
68
+
69
+ def executable(self):
70
+ # prints env var named by first arg
71
+ return "python3 -c 'import os,sys;print(os.getenv(sys.argv[1]))'"
72
+
73
+
74
+ def test_wrapper_shell_true_allows_command_string(stub_env, capfd):
75
+ """Test that Wrapper with shell=True runs command string."""
76
+ w = HelloWrapper("hello", ["ROOT"])
77
+ rc = w.launch()
78
+ out, err = capfd.readouterr()
79
+ assert rc == 0
80
+ assert out.strip() == "/tmp/envstack-root"
81
+
82
+
83
+ def test_commandwrapper_runs_argv_without_shell(stub_env, capfd):
84
+ """Test that CommandWrapper runs argv mode without shell."""
85
+ w = CommandWrapper("hello", ["echo", "hi there"])
86
+ rc = w.launch()
87
+ out, err = capfd.readouterr()
88
+ assert rc == 0
89
+ assert out.strip() == "hi there"
90
+
91
+
92
+ def test_run_command_preserves_quoted_arg_in_argv_mode(stub_env):
93
+ """Test that run_command preserves quoted args when shell=False."""
94
+ # Regression for: envstack -- bash -c "sleep 5" breaking into bash -c sleep 5
95
+ # Here we use something observable instead of sleep.
96
+ rc = run_command(["bash", "-c", "printf '%s\n' \"sleep 5\""], namespace="hello")
97
+ assert rc == 0
98
+
99
+
100
+ def test_run_command_brace_expands_to_env_value(stub_env, capfd):
101
+ """Test that {VAR} in command args expands to env var value."""
102
+ rc = run_command(["echo", "{ROOT}"], namespace="hello")
103
+ out, err = capfd.readouterr()
104
+ assert rc == 0
105
+ assert out.strip() == "/tmp/envstack-root"
106
+
107
+
108
+ def test_run_command_two_in_series_no_stop(stub_env, capfd):
109
+ """Test that two commands run in series without stopping the process."""
110
+ rc1 = run_command(["bash", "-c", "echo one"], namespace="hello")
111
+ rc2 = run_command(["bash", "-c", "echo two"], namespace="hello")
112
+ out, err = capfd.readouterr()
113
+ lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
114
+ assert rc1 == 0 and rc2 == 0
115
+ assert lines[-2:] == ["one", "two"]
116
+
117
+
118
+ @pytest.mark.parametrize(
119
+ "override, expected",
120
+ [
121
+ ("1", True),
122
+ ("true", True),
123
+ ("yes", True),
124
+ ("on", True),
125
+ ("0", False),
126
+ ("false", False),
127
+ ("no", False),
128
+ ("off", False),
129
+ ],
130
+ )
131
+ def test_interactive_env_override_parsing(stub_env, monkeypatch, override, expected):
132
+ env = dict(os.environ)
133
+ env["INTERACTIVE"] = override
134
+
135
+ # example: if you implemented ShellWrapper.get_interactive(env)
136
+ ShellWrapper = getattr(wrapper_mod, "ShellWrapper", None)
137
+ if ShellWrapper is None:
138
+ pytest.skip("ShellWrapper not present in envstack.wrapper")
139
+
140
+ sw = ShellWrapper("hello", "echo test")
141
+ assert sw.get_interactive(env) is expected
File without changes
File without changes
File without changes