diff --git a/docs/docs/References/Platform_Libraries.md b/docs/docs/References/Platform_Libraries.md index 82392d48..38494823 100644 --- a/docs/docs/References/Platform_Libraries.md +++ b/docs/docs/References/Platform_Libraries.md @@ -88,12 +88,13 @@ print response.stderr ##### Using parameters to construct a bash command. +Note: see the helper function [construct_bash_command_string](#construct_bash_command_string) ```python from dlpx.virtualization import libs name = virtual_source.parameters.username port = virtual_source.parameters.port -command = "mysqldump -u {} -p {}".format(name,port) +command = libs.construct_bash_command_string("mysqldump", "-u", name, "-p", port) response = libs.run_bash(connection, command) ``` @@ -124,6 +125,34 @@ response = libs.run_bash(connection, command) ``` For more information please go to [Managing Scripts for Remote Execution](/Best_Practices/Managing_Scripts_For_Remote_Execution.md) section. +## construct_bash_command_string + +Constructs a full Bash command string from an executable name and list of args. + +This helper function is intended to help with the simple case of running a remote +command with a set of arguments. By using this function, you won't have to worry +about the details of escaping/quoting special characters. + +### Signature +`def construct_bash_command_string(executable, args)` + +### Arguments +Argument | Type | Description +-------- | ---- | ----------- +executable | String | Name of the command. This can be the full path to the command, the name of a command that is found on the path, or a shell builtin. +args | List of Strings | Arguments to the command + +### Returns +A string representing a full Bash command line, that can be passed to run_bash. + +### Example +```python +old_name = "A filename with spaces in it" +new_name = "The file's new $100 name" # <-- note special characters! +move_command = libs.construct_bash_command_string("mv", old_name, new_name) +libs.run_bash(cx, move_command) +``` + ## run_expect Executes a tcl command or script on a remote Unix host. @@ -151,7 +180,7 @@ stderr | String | Stderr from the command. ### Example -Calling expect with an inline command. +Calling expect with an inline command. ```python from dlpx.virtualization import libs diff --git a/libs/src/main/python/dlpx/virtualization/libs/libs.py b/libs/src/main/python/dlpx/virtualization/libs/libs.py index 50b57d5c..f5d11be5 100644 --- a/libs/src/main/python/dlpx/virtualization/libs/libs.py +++ b/libs/src/main/python/dlpx/virtualization/libs/libs.py @@ -43,6 +43,7 @@ __all__ = [ "run_bash", + "construct_bash_command_string", "run_sync", "run_powershell", "run_expect", @@ -96,6 +97,82 @@ def _check_exit_code(response, check): response.return_value.stderr)) +def _quote_bash_word(unquoted_string): + """ + This function takes the given input string, and returns a string that can + be passed to Bash, such that Bash will interpret it as a unitary word. + + So, the incoming string can have quotes, dollar signs, spaces, etc. These + characters will be quoted/escaped such that Bash does not interpret them + specially. + + The returned string can be used as part of a command string passed to + run_bash. + + The technique is to enclose everything in single quotes, except for single- + quote characters, which are enclosed by double quotes. + + For example, consider this python string that we want to pass to a program: + a'b"c$f$o$o + + We cannot simply pass this string to Bash as-is, because Bash would treat + the dollar signs and quote characters specially. + + For this case, this function would then return the following string: + 'a'"'"'c$f$o$o' + + Breaking this down, the returned string contains three separate items: + 1) 'a' (a single-quoted string containing no special characters) + 2) "'" (a double-quoted string containing a special character) + 3) 'c$f$o$o' (a single-quoted string containing some special characters) + Because of the quoting, none of the special characters above will be + interpreted by Bash, and will be left as-is. Bash will remove the outer + quotes on each of these three parts, and then mash the three parts together + into one string. + + Thus, once Bash does its interpretation of the string, the result will have + the same content as the original Python string: + a'b"c$f$o$o + """ + + # We want to enclose the whole string in single quotes. + # But, any time we see a single-quote in the given string, we need to: + # 1) Close the existing single-quoted section + # 2) Start a new double-quoted section + # 3) Insert the original single-quote character + # 4) Close the double-quoted section that we just opened + # 5) Open a new single-quoted section for the remainder of the string + return "'{}'".format(unquoted_string.replace("'", "'\"'\"'")) + + +def construct_bash_command_string(executable, *args): + """ + Helper function to assemble a full command string to pass to run_bash. + + run_bash expects a single string representing the entire command/script that + is to be run. This is sometimes inconvenient and bug-prone for the simple + case when you want to run a single command with some arguments. + + For example, suppose you want to change a filename. You might be tempted to + write this: + run_bash(cx, "mv {} {}".format(oldname, newname)) + + That code will fail to work if either of the two names has a space or other + special character in it. This function is meant to help with such cases. + So, the above example can change to this: + run_bash(cx, construct_bash_command_string("mv", oldname, newname)) + + This method will worry about all of the quoting and escaping necessary. + """ + assert executable + exec_string = _quote_bash_word(executable) + if args: + arg_string = " ".join([_quote_bash_word(a) for a in args]) + return "{} {}".format(exec_string, arg_string) + else: + return exec_string + + def run_bash(remote_connection, command, variables=None, use_login_shell=False, check=False): """run_bash operation wrapper. diff --git a/libs/src/test/python/dlpx/virtualization/test_libs.py b/libs/src/test/python/dlpx/virtualization/test_libs.py index abf8eba0..f997218f 100644 --- a/libs/src/test/python/dlpx/virtualization/test_libs.py +++ b/libs/src/test/python/dlpx/virtualization/test_libs.py @@ -236,6 +236,38 @@ def test_run_bash_bad_use_login_shell(remote_connection): " class 'str' but should be of class 'bool' if defined.") +class TestLibsConstructBashCommand: + @staticmethod + def test_no_args(): + result = libs.construct_bash_command_string("/path/to/executable") + assert result == "'/path/to/executable'" + + @staticmethod + def test_single_arg(): + result = libs.construct_bash_command_string("foo", "bar") + assert result == "'foo' 'bar'" + + @staticmethod + def test_many_args(): + result = libs.construct_bash_command_string("a", "b", "c", "d", "e") + assert result == "'a' 'b' 'c' 'd' 'e'" + + @staticmethod + def test_single_quote_escaping(): + result = libs.construct_bash_command_string("a'b'c", "e'f'g") + assert result == "'a'\"'\"'b'\"'\"'c' 'e'\"'\"'f'\"'\"'g'" + + @staticmethod + def test_double_quote_escaping(): + result = libs.construct_bash_command_string("a\"b\"c", "e\"f\"g") + assert result == "'a\"b\"c' 'e\"f\"g'" + + @staticmethod + def test_combined_escaping(): + result = libs.construct_bash_command_string("a'b\"c$f$o$o") + assert result == "'a'\"'\"'b\"c$f$o$o'" + + class TestLibsRunSync: @staticmethod def test_run_sync(remote_connection):