diff --git a/app/util/process_utils.py b/app/util/process_utils.py index 106b1cc..6b67559 100644 --- a/app/util/process_utils.py +++ b/app/util/process_utils.py @@ -1,4 +1,6 @@ from contextlib import suppress +import os +import subprocess from subprocess import TimeoutExpired @@ -23,3 +25,26 @@ def kill_gracefully(process, timeout=2): stdout, stderr = process.communicate() return process.returncode, stdout, stderr + + +def Popen_with_delayed_expansion(cmd, *args, **kwargs): + """ + A thin wrapper around subprocess.Popen which ensures that all environment variables in the cmd are expanded at + execution time. By default, Windows CMD *disables* delayed expansion which means it will expand the command first + before execution. E.g. run 'set FOO=1 && echo %FOO%' won't actually echo 1 because %FOO% gets expanded before the + execution. + + :param cmd: The command to execute + :type cmd: str | iterable + + :return: Popen object, just like the Popen object returned by subprocess.Popen + :rtype: :class:`Popen` + """ + if os.name == 'nt': + cmd_with_deplayed_expansion = ['cmd', '/V', '/C'] + if isinstance(cmd, str): + cmd_with_deplayed_expansion.append(cmd) + else: + cmd_with_deplayed_expansion.extend(cmd) + cmd = cmd_with_deplayed_expansion + return subprocess.Popen(cmd, *args, **kwargs) diff --git a/test/unit/util/test_process_utils.py b/test/unit/util/test_process_utils.py new file mode 100644 index 0000000..1c232f5 --- /dev/null +++ b/test/unit/util/test_process_utils.py @@ -0,0 +1,43 @@ +from genty import genty, genty_dataset + +from app.util.process_utils import Popen_with_delayed_expansion + +from test.framework.base_unit_test_case import BaseUnitTestCase + + +@genty +class TestProcessUtils(BaseUnitTestCase): + + @genty_dataset( + str_cmd_on_windows=( + 'set FOO=1 && echo !FOO!', + 'nt', + ['cmd', '/V', '/C', 'set FOO=1 && echo !FOO!'], + ), + list_cmd_on_windows=( + ['set', 'FOO=1', '&&', 'echo', '!FOO!'], + 'nt', + ['cmd', '/V', '/C', 'set', 'FOO=1', '&&', 'echo', '!FOO!'], + ), + str_cmd_on_posix=( + 'export FOO=1; echo $FOO', + 'posix', + 'export FOO=1; echo $FOO', + ), + list_cmd_on_posix=( + ['export', 'FOO=1;', 'echo', '$FOO'], + 'posix', + ['export', 'FOO=1;', 'echo', '$FOO'], + ), + ) + def test_Popen_with_deplayed_expansion(self, input_cmd, os_name, expected_final_cmd): + # Arrange + mock_os = self.patch('app.util.process_utils.os') + mock_os.name = os_name + mock_subprocess_popen = self.patch('subprocess.Popen') + + # Act + Popen_with_delayed_expansion(input_cmd) + + # Assert + mock_subprocess_popen.assert_called_once_with(expected_final_cmd)