Repository: conan-io/conan

Issue:
[suggestion] Pass command to Runner as a sequence instead of string
To help us debug your issue please explain:

- [x] I've read the [CONTRIBUTING guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.

Conan-1.18.1

Conan's `Runner` class (and through it, `ConanFile`'s `run()` method) only support commands passed as strings. This forces users to correctly quote all arguments, which is a [frequent](https://github.com/conan-io/conan/pull/2555) [source](https://github.com/conan-io/conan/pull/1641) [of](https://github.com/conan-io/conan/issues/2185) [bugs](https://github.com/conan-io/conan/pull/2313) because problems only manifest when certain characters occur in option values or path names.

What are people's thoughts on adding support for specifying the command as a sequence as an alternative to a string? The underlying Python call `subprocess.Popen()` already supports this, and `os.system()` can be replaced by `subprocess.run()` which does this as well. An `env` parameter will probably need to be added to support setting `DYLD_LIBRARY_PATH` in `ConanFile`'s `run()` method.

It is possible to seamlessly support commands as both strings and sequences, so I expect this change to be fully backwards-compatible from the perspective of recipes. In my opinion, it would be a huge improvement to Conan's usability and safety.

If there's any interest for this to go ahead, I'm more than willing to provide a PR.


Discussion:

Resolving patch:
diff --git a/conans/client/runner.py b/conans/client/runner.py
--- a/conans/client/runner.py
+++ b/conans/client/runner.py
@@ -51,7 +51,7 @@ def __call__(self, command, output=True, log_filepath=None, cwd=None, subprocess
             log_filepath = None
 
         # Log the command call in output and logger
-        call_message = "\n----Running------\n> %s\n-----------------\n" % command
+        call_message = "\n----Running------\n> %s\n-----------------\n" % (command,)
         if self._print_commands_to_output and stream_output and self._log_run_to_output:
             stream_output.write(call_message)
 
@@ -62,7 +62,7 @@ def __call__(self, command, output=True, log_filepath=None, cwd=None, subprocess
                 return self._simple_os_call(command, cwd)
             elif log_filepath:
                 if stream_output:
-                    stream_output.write("Logging command output to file '%s'\n" % log_filepath)
+                    stream_output.write("Logging command output to file '%s'\n" % (log_filepath,))
                 with open(log_filepath, "a+") as log_handler:
                     if self._print_commands_to_output:
                         log_handler.write(call_message)
@@ -76,7 +76,7 @@ def _pipe_os_call(self, command, stream_output, log_handler, cwd):
             # piping both stdout, stderr and then later only reading one will hang the process
             # if the other fills the pip. So piping stdout, and redirecting stderr to stdout,
             # so both are merged and use just a single get_stream_lines() call
-            proc = Popen(command, shell=True, stdout=PIPE, stderr=STDOUT, cwd=cwd)
+            proc = Popen(command, shell=isinstance(command, six.string_types), stdout=PIPE, stderr=STDOUT, cwd=cwd)
         except Exception as e:
             raise ConanException("Error while executing '%s'\n\t%s" % (command, str(e)))
 
@@ -108,12 +108,12 @@ def get_stream_lines(the_stream):
 
     def _simple_os_call(self, command, cwd):
         if not cwd:
-            return os.system(command)
+            return subprocess.call(command, shell=isinstance(command, six.string_types))
         else:
             try:
                 old_dir = get_cwd()
                 os.chdir(cwd)
-                result = os.system(command)
+                result = subprocess.call(command, shell=isinstance(command, six.string_types))
             except Exception as e:
                 raise ConanException("Error while executing"
                                      " '%s'\n\t%s" % (command, str(e)))
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -1,5 +1,6 @@
 import os
 from contextlib import contextmanager
+from six import string_types
 
 import six
 
@@ -271,7 +272,9 @@ def _run():
             # When using_build_profile the required environment is already applied through 'conanfile.env'
             # in the contextmanager 'get_env_context_manager'
             with tools.run_environment(self) if not self._conan_using_build_profile else no_op():
-                if OSInfo().is_macos:
+                if OSInfo().is_macos and isinstance(command, string_types):
+                    # Security policy on macOS clears this variable when executing /bin/sh. To
+                    # keep its value, set it again inside the shell when running the command.
                     command = 'DYLD_LIBRARY_PATH="%s" DYLD_FRAMEWORK_PATH="%s" %s' % \
                               (os.environ.get('DYLD_LIBRARY_PATH', ''),
                                os.environ.get("DYLD_FRAMEWORK_PATH", ''),


Test patch:
diff --git a/conans/test/functional/environment/run_environment_test.py b/conans/test/functional/environment/run_environment_test.py
--- a/conans/test/functional/environment/run_environment_test.py
+++ b/conans/test/functional/environment/run_environment_test.py
@@ -31,7 +31,7 @@ class HelloConan(ConanFile):
                 version = "0.1"
                 build_policy = "missing"
                 requires = "Hello0/0.1@lasote/stable"
-            
+
                 def build(self):
                     run_env = RunEnvironment(self)
                     with tools.environment_append(run_env.vars):
@@ -65,7 +65,7 @@ def setUp(self):
             #else
               #define HELLO_EXPORT
             #endif
-            
+
             HELLO_EXPORT void hello();
         """)
 
@@ -92,7 +92,7 @@ def build(self):
                     cmake = CMake(self)
                     cmake.configure()
                     cmake.build()
-            
+
                 def package(self):
                     self.copy("*say_hello.exe", dst="bin", keep_path=False)
                     self.copy("*say_hello", dst="bin", keep_path=False)
@@ -129,6 +129,39 @@ def build(self):
         client.run("build .")
         self.assertIn("Hello Tool!", client.out)
 
+    @unittest.skipUnless(platform.system() == "Darwin", "Check SIP protection shell=False")
+    def test_command_as_list(self):
+        client = TestClient(servers=self.servers, users={"default": [("lasote", "mypass")]})
+
+        reuse = textwrap.dedent("""
+            from conans import ConanFile
+            class HelloConan(ConanFile):
+                requires = "Pkg/0.1@lasote/testing"
+                options = {"cmd_list": [True, False]}
+                default_options = {"cmd_list": False}
+
+                def build(self):
+                    if self.options.cmd_list:
+                        self.run(["say_hello",], run_environment=True)
+                    else:
+                        self.run("say_hello", run_environment=True)
+        """)
+
+        client.save({"conanfile.py": reuse}, clean_first=True)
+        client.run("config set log.print_run_commands=1")
+
+        # Using a string, a new shell is expanded, DYLD_... paths has to be informed
+        client.run("install . -o cmd_list=False")
+        client.run("build .")
+        self.assertIn("> DYLD_LIBRARY_PATH=", client.out)
+        self.assertIn("Hello Tool!", client.out)
+
+        # Using a list, no new shell, DYLD_... are already in the environment
+        client.run("install . -o cmd_list=True")
+        client.run("build .")
+        self.assertNotIn("DYLD_LIBRARY_PATH", client.out)
+        self.assertIn("Hello Tool!", client.out)
+
     @unittest.skipIf(platform.system() == "Darwin", "SIP protection (read comment)")
     def test_with_tools_run_environment(self):
         # This test is excluded from OSX, because of the SIP protection. CMake helper will