Skip to content

Commit 887b00a

Browse files
authored
Command Output (#107)
* initial support for capturing output of commands * adds wrapper integration tests, pass command through in capture_output for cmd * updates env vars in api docs
1 parent 5000acd commit 887b00a

12 files changed

Lines changed: 382 additions & 30 deletions

File tree

docs/api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ The following environment variables are used to help manage functionality:
7070

7171
| Name | Description |
7272
|------|-------------|
73-
| DEFAULT_ENV_STACK | Name of the default environment stack (default) |
73+
| ALLOW_COMMANDS | Allow embedded commands |
74+
| COMMAND_TIMEOUT | Embedded command timeout in seconds |
75+
| DEFAULT_NAMESPACE | Name of the default environment stack (default) |
7476
| ENVPATH | Colon-separated paths to search for environment files |
7577
| IGNORE_MISSING | Ignore missing stack files when resolving environments |
7678
| INTERACTIVE | Force shells to run in interactive mode |

examples/default/test.env

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env envstack
2+
include: [default]
3+
all: &all
4+
PYVERSION: $(python -c "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')")
5+
PYTHONPATH: ${DEPLOY_ROOT}/lib/python${PYVERSION}
6+
darwin:
7+
<<: *all
8+
linux:
9+
<<: *all
10+
windows:
11+
<<: *all

lib/envstack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@
3737
__version__ = "0.9.6"
3838

3939
from envstack.env import clear, init, revert, save # noqa: F401
40-
from envstack.env import load_environ, resolve_environ # noqa: F401
40+
from envstack.env import load_environ, resolve_environ # noqa: F401

lib/envstack/config.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,32 @@ def detect_shell():
5757
return "/usr/bin/bash"
5858

5959

60+
# debug mode
6061
DEBUG = os.getenv("DEBUG")
62+
63+
# default namespace
6164
DEFAULT_NAMESPACE = os.getenv("DEFAULT_ENV_STACK", "default")
65+
66+
# allow embedded commands
67+
ALLOW_COMMANDS = os.getenv("ALLOW_COMMANDS", "0") in ("1", "true", "True", "TRUE")
68+
69+
# embedded command timeout in seconds
70+
try:
71+
COMMAND_TIMEOUT = int(os.getenv("COMMAND_TIMEOUT", 5))
72+
except ValueError:
73+
COMMAND_TIMEOUT = 5
74+
75+
# default environment variables
6276
ENV = os.getenv("ENV", "prod")
6377
HOME = os.getenv("HOME")
64-
IGNORE_MISSING = bool(os.getenv("IGNORE_MISSING", 1))
78+
79+
# Ignore missing stack files when resolving environments
80+
IGNORE_MISSING = os.getenv("IGNORE_MISSING", "1") in ("1", "true", "True", "TRUE")
81+
82+
# logging level
6583
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
84+
85+
# platform and shell info
6686
ON_POSIX = "posix" in sys.builtin_module_names
6787
PLATFORM = platform.system().lower()
6888
PYTHON_VERSION = sys.version_info[0]

lib/envstack/util.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}"
6262
)
6363

64+
# regular expression pattern for command substitution
65+
cmdsub_pattern = re.compile(r"^\s*\$\((?P<cmd>.*)\)\s*$", re.DOTALL)
66+
6467

6568
def cache(func):
6669
"""Function decorator to memoize return data."""
@@ -444,6 +447,9 @@ def substitute_variable(match):
444447
)
445448

446449
elif operator is None:
450+
# check for command substitution
451+
if config.ALLOW_COMMANDS and cmdsub_pattern.match(str(current)):
452+
return str(evaluate_command(current))
447453
if is_literal(current):
448454
return current # file literal wins
449455
if is_template(current):
@@ -493,7 +499,36 @@ def substitute_variable(match):
493499
else:
494500
result = expression
495501

496-
return sanitize_value(result)
502+
resolved_value = sanitize_value(result)
503+
504+
# command substitution
505+
if config.ALLOW_COMMANDS and isinstance(resolved_value, str):
506+
resolved_value = evaluate_command(resolved_value)
507+
508+
return resolved_value
509+
510+
511+
def evaluate_command(command: str):
512+
"""
513+
Evaluates command substitution in the given string.
514+
515+
PYVERSION: $(python --version)
516+
517+
:param command: The command string to evaluate.
518+
:returns: The command output or original string if no command found.
519+
"""
520+
521+
from envstack.wrapper import capture_output
522+
523+
match = cmdsub_pattern.match(command)
524+
if match:
525+
exit_code, out, err = capture_output(match.group(1))
526+
if exit_code == 0:
527+
return out.strip()
528+
else:
529+
return err.strip() or null
530+
531+
return command
497532

498533

499534
def load_sys_path(

lib/envstack/wrapper.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,68 @@ def get_subprocess_command(self, env):
236236
return list(self._subprocess_argv)
237237

238238

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 (
290+
cmd[0] if isinstance(cmd, list) and cmd else str(command)
291+
)
292+
return 127, "", f"{missing}: command not found"
293+
except OSError as e:
294+
# Other OS-level execution errors (permission, exec format, etc.)
295+
rc = 126 if getattr(e, "errno", None) in (errno.EACCES,) else 1
296+
return rc, "", str(e)
297+
except subprocess.TimeoutExpired as e:
298+
return 124, "", f"Command timed out after {timeout} seconds"
299+
300+
239301
def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):
240302
"""
241303
Runs a given command with the given stack namespace.

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
markers =
3+
integration: tests that run real subprocesses (slower / OS-dependent)

tests/fixtures/env/test.env

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env envstack
2+
include: [default]
3+
all: &all
4+
PYVERSION: $(python -c "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')")
5+
PYTHONPATH: ${DEPLOY_ROOT}/lib/python${PYVERSION}
6+
darwin:
7+
<<: *all
8+
linux:
9+
<<: *all
10+
windows:
11+
<<: *all

tests/test_cmds.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ def test_default(self):
9999
"""
100100
% self.root
101101
)
102-
output = subprocess.check_output(self.envstack_bin, shell=True, universal_newlines=True)
102+
output = subprocess.check_output(
103+
self.envstack_bin, shell=True, universal_newlines=True
104+
)
103105
self.assertEqual(output, expected_output)
104106

105107
def test_dev(self):
@@ -598,6 +600,21 @@ def test_project_echo_deploy_root_foobar(self):
598600
)
599601
self.assertEqual(output, expected_output)
600602

603+
def test_test_echo_pyversion(self):
604+
"""Tests the test stack with an echo command."""
605+
command = "%s test -- echo {PYVERSION}" % self.envstack_bin
606+
expected_output = f"{sys.version_info[0]}.{sys.version_info[1]}\n"
607+
env = os.environ.copy()
608+
env["ALLOW_COMMANDS"] = "1"
609+
output = subprocess.check_output(
610+
command,
611+
start_new_session=True,
612+
env=env,
613+
shell=True,
614+
universal_newlines=True,
615+
)
616+
self.assertEqual(output, expected_output)
617+
601618

602619
class TestSet(unittest.TestCase):
603620
"""Tests various envstack set commands."""
@@ -791,15 +808,15 @@ def test_dev_deploy_root(self):
791808
)
792809
self.assertEqual(output, expected_output)
793810

794-
def test_test_deploy_root(self):
811+
def test_project_deploy_root(self):
795812
command = "ENV=invalid %s project -- %s" % (self.envstack_bin, self.python_cmd)
796813
expected_output = f"{self.root}/project\n"
797814
output = subprocess.check_output(
798815
command, start_new_session=True, shell=True, universal_newlines=True
799816
)
800817
self.assertEqual(output, expected_output)
801818

802-
def test_foobar_deploy_root(self):
819+
def test_project_foobar_deploy_root(self):
803820
command = "ENV=invalid %s project foobar -- %s" % (
804821
self.envstack_bin,
805822
self.python_cmd,

tests/test_env.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,10 @@ def bake_encrypted_environ(self, stack_name):
805805
if key == "STACK": # skip the stack name
806806
continue
807807
encrypted_value = encrypted[key]
808-
self.assertTrue(isinstance(encrypted_value, EncryptedNode), f"type is {type(encrypted_value)}")
808+
self.assertTrue(
809+
isinstance(encrypted_value, EncryptedNode),
810+
f"type is {type(encrypted_value)}",
811+
)
809812
self.assertEqual(encrypted_value.original_value, None)
810813
# self.assertNotEqual(encrypted_value.original_value, value) # from_yaml only
811814
self.assertEqual(encrypted_value.value, value)
@@ -845,7 +848,9 @@ def resolve_encrypted_environ(self, stack_name):
845848
self.assertNotEqual(encrypted_value, value)
846849
self.assertNotEqual(encrypted_value, resolved_value)
847850
# self.assertEqual(resolved_value, encrypted_resolved_value)
848-
if isinstance(resolved_value, str) and isinstance(encrypted_resolved_value, str):
851+
if isinstance(resolved_value, str) and isinstance(
852+
encrypted_resolved_value, str
853+
):
849854
self.assertTrue(
850855
resolved_value.startswith(encrypted_resolved_value),
851856
f"{encrypted_resolved_value} not prefix of {resolved_value}",

0 commit comments

Comments
 (0)