Repository: conan-io/conan

Issue:
[feature] Accept list for SystemPackageTool
The [SystemPackageTool](https://docs.conan.io/en/latest/reference/conanfile/methods.html#systempackagetool) class offers the method `install` which only supports a string with all packages to be installed. However, even a list of packages must be passed as a string, it's a bit confuse, and get worst if you need to check if it's installed or not before: https://github.com/conan-io/conan/issues/6960#issuecomment-624620287

It would be great accepting both string (backward) and list:

```
installer = SystemPackageTool()
installer.install(["pkg1", "pkg2", "pkg3"])
```

- [x] I've read the [CONTRIBUTING guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).



Discussion:
The documentation states it can use a list (in a single string) and the implementation tries to work with a list too:

```python
    def install(self, packages, update=True, force=False, arch_names=None):
        """ Get the system package tool install command.

        :param packages: String with all package to be installed e.g. "libusb-dev libfoobar-dev"
        
        """
        packages = [packages] if isinstance(packages, str) else list(packages)
```

Probably this is not a feature, but a bug as it is not working as expected (and it has already the ability to work with a list/string-list). Here we need to fix a couple of things and probably fixing them will cover this feature.

I'm closing this one in favor of https://github.com/conan-io/conan/issues/6960

Resolving patch:
diff --git a/conans/client/tools/system_pm.py b/conans/client/tools/system_pm.py
--- a/conans/client/tools/system_pm.py
+++ b/conans/client/tools/system_pm.py
@@ -1,5 +1,6 @@
 import os
 import sys
+import six
 
 from conans.client.runner import ConanRunner
 from conans.client.tools.oss import OSInfo, cross_building, get_cross_building_settings
@@ -92,9 +93,9 @@ def update(self):
         self._tool.update()
 
     def install(self, packages, update=True, force=False, arch_names=None):
-        """ Get the system package tool install command.
+        """ Get the system package tool install command and install one package
 
-        :param packages: String with all package to be installed e.g. "libusb-dev libfoobar-dev"
+        :param packages: String with a package to be installed or a list with its variants e.g. "libusb-dev libxusb-devel"
         :param update: Run update command before to install
         :param force: Force installing all packages
         :param arch_names: Package suffix/prefix name used by installer tool e.g. {"x86_64": "amd64"}
@@ -126,6 +127,63 @@ def install(self, packages, update=True, force=False, arch_names=None):
             self.update()
         self._install_any(packages)
 
+    def install_packages(self, packages, update=True, force=False, arch_names=None):
+        """ Get the system package tool install command and install all packages and/or variants.
+            Inputs:
+            "pkg-variant1"  # (1) Install only one package
+            ["pkg-variant1", "otherpkg", "thirdpkg"] # (2) All must be installed
+            [("pkg-variant1", "pkg-variant2"), "otherpkg", "thirdpkg"] # (3) Install only one variant
+                                                                             and all other packages
+            "pkg1 pkg2", "pkg3 pkg4" # (4) Invalid
+            ["pkg1 pkg2", "pkg3 pkg4"] # (5) Invalid
+
+        :param packages: Supports multiple formats (string,list,tuple). Lists and tuples into a list
+        are considered variants and is processed just like self.install(). A list of string is
+        considered a list of packages to be installed (only not installed yet).
+        :param update: Run update command before to install
+        :param force: Force installing all packages, including all installed.
+        :param arch_names: Package suffix/prefix name used by installer tool e.g. {"x86_64": "amd64"}
+        :return: None
+        """
+        packages = [packages] if isinstance(packages, six.string_types) else list(packages)
+        # only one (first) variant will be installed
+        list_variants = list(filter(lambda x: isinstance(x, (tuple, list)), packages))
+        # all packages will be installed
+        packages = list(filter(lambda x: not isinstance(x, (tuple, list)), packages))
+
+        if [pkg for pkg in packages if " " in pkg]:
+            raise ConanException("Each string must contain only one package to be installed. "
+                                 "Use a list instead e.g. ['foo', 'bar'].")
+
+        for variant in list_variants:
+            self.install(variant, update=update, force=force, arch_names=arch_names)
+
+        packages = self._get_package_names(packages, arch_names)
+
+        mode = self._get_sysrequire_mode()
+
+        if mode in ("verify", "disabled"):
+            # Report to output packages need to be installed
+            if mode == "disabled":
+                self._output.info("The following packages need to be installed:\n %s"
+                                  % "\n".join(packages))
+                return
+
+            if mode == "verify" and not self._installed(packages):
+                self._output.error("The following packages need to be installed:\n %s"
+                                   % "\n".join(packages))
+                raise ConanException("Aborted due to CONAN_SYSREQUIRES_MODE=%s. "
+                                     "Some system packages need to be installed" % mode)
+
+        packages = packages if force else self._to_be_installed(packages)
+        if not force and not packages:
+            return
+
+        # From here system packages can be updated/modified
+        if update and not self._is_up_to_date:
+            self.update()
+        self._install_all(packages)
+
     def _get_package_names(self, packages, arch_names):
         """ Parse package names according it architecture
 
@@ -138,16 +196,27 @@ def _get_package_names(self, packages, arch_names):
             arch = host_arch or build_arch
             parsed_packages = []
             for package in packages:
-                for package_name in package.split(" "):
-                    parsed_packages.append(self._tool.get_package_name(package_name, arch,
-                                                                       arch_names))
+                if isinstance(package, (tuple, list)):
+                    parsed_packages.append(tuple(self._get_package_names(package, arch_names)))
+                else:
+                    for package_name in package.split(" "):
+                        parsed_packages.append(self._tool.get_package_name(package_name, arch,
+                                                                           arch_names))
             return parsed_packages
         return packages
 
     def installed(self, package_name):
         return self._tool.installed(package_name)
 
+    def _to_be_installed(self, packages):
+        """ Returns a list with all not installed packages.
+        """
+        not_installed = [pkg for pkg in packages if not self._tool.installed(pkg)]
+        return not_installed
+
     def _installed(self, packages):
+        """ Return True if at least one of the packages is installed.
+        """
         if not packages:
             return True
 
@@ -157,6 +226,9 @@ def _installed(self, packages):
                 return True
         return False
 
+    def _install_all(self, packages):
+        self._tool.install(" ".join(sorted(packages)))
+
     def _install_any(self, packages):
         if len(packages) == 1:
             return self._tool.install(packages[0])


Test patch:
diff --git a/conans/test/unittests/client/tools/system_pm_test.py b/conans/test/unittests/client/tools/system_pm_test.py
--- a/conans/test/unittests/client/tools/system_pm_test.py
+++ b/conans/test/unittests/client/tools/system_pm_test.py
@@ -14,6 +14,16 @@
 from conans.test.utils.mocks import MockSettings, MockConanfile, TestBufferConanOutput
 
 
+class RunnerMultipleMock(object):
+    def __init__(self, expected=None):
+        self.calls = 0
+        self.expected = expected
+
+    def __call__(self, command, *args, **kwargs):  # @UnusedVariable
+        self.calls += 1
+        return 0 if command in self.expected else 1
+
+
 class SystemPackageToolTest(unittest.TestCase):
     def setUp(self):
         self.out = TestBufferConanOutput()
@@ -352,15 +362,6 @@ def test_system_package_tool(self, patched_with_apt):
                                  'findstr /c:"1 packages installed."')
 
     def test_system_package_tool_try_multiple(self):
-        class RunnerMultipleMock(object):
-            def __init__(self, expected=None):
-                self.calls = 0
-                self.expected = expected
-
-            def __call__(self, command, output):  # @UnusedVariable
-                self.calls += 1
-                return 0 if command in self.expected else 1
-
         packages = ["a_package", "another_package", "yet_another_package"]
         with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
             runner = RunnerMultipleMock(['dpkg-query -W -f=\'${Status}\' another_package | '
@@ -387,16 +388,6 @@ def test_system_package_tool_mode(self):
         Allowed values: (enabled, verify, disabled). Parser accepts it in lower/upper
         case or any combination.
         """
-
-        class RunnerMultipleMock(object):
-            def __init__(self, expected=None):
-                self.calls = 0
-                self.expected = expected
-
-            def __call__(self, command, *args, **kwargs):  # @UnusedVariable
-                self.calls += 1
-                return 0 if command in self.expected else 1
-
         packages = ["a_package", "another_package", "yet_another_package"]
 
         # Check invalid mode raises ConanException
@@ -526,3 +517,143 @@ def get_linux_error_message():
                 spt.update()
         else:
             spt.update()  # Won't raise anything because won't do anything
+
+    def test_install_all_packages(self):
+        """ SystemPackageTool must install all packages
+        """
+        # No packages installed
+        packages = ["a_package", "another_package", "yet_another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(["sudo -A apt-get update",
+                                         "sudo -A apt-get install -y --no-install-recommends"
+                                         " a_package another_package yet_another_package",
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(5, runner.calls)
+
+    def test_install_few_packages(self):
+        """ SystemPackageTool must install 2 packages only
+        """
+        packages = ["a_package", "another_package", "yet_another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(['dpkg-query -W -f=\'${Status}\' a_package | '
+                                         'grep -q "ok installed"',
+                                         "sudo -A apt-get update",
+                                         "sudo -A apt-get install -y --no-install-recommends"
+                                         " another_package yet_another_package",
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(5, runner.calls)
+
+    def test_packages_installed(self):
+        """ SystemPackageTool must not install. All packages are installed.
+        """
+        packages = ["a_package", "another_package", "yet_another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(['dpkg-query -W -f=\'${Status}\' a_package | '
+                                         'grep -q "ok installed"',
+                                         'dpkg-query -W -f=\'${Status}\' another_package | '
+                                         'grep -q "ok installed"',
+                                         'dpkg-query -W -f=\'${Status}\' yet_another_package | '
+                                         'grep -q "ok installed"',
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(3, runner.calls)
+
+    def test_empty_package_list(self):
+        """ Install nothing
+        """
+        packages = []
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock()
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out),
+                                    output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(0, runner.calls)
+
+    def test_install_variants_and_packages(self):
+        """ SystemPackageTool must install one of variants and all packages at same list
+        """
+        packages = [("varianta", "variantb", "variantc"), "a_package", "another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(["sudo -A apt-get update",
+                                         "sudo -A apt-get install -y --no-install-recommends"
+                                         " varianta",
+                                         "sudo -A apt-get update",
+                                         "sudo -A apt-get install -y --no-install-recommends"
+                                         " a_package another_package",
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(8, runner.calls)
+
+    def test_installed_variant_and_install_packages(self):
+        """ Only packages must be installed. Variants are already installed
+        """
+        packages = [("varianta", "variantb", "variantc"), "a_package", "another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(['dpkg-query -W -f=\'${Status}\' varianta | '
+                                         'grep -q "ok installed"',
+                                         "sudo -A apt-get update",
+                                         "sudo -A apt-get install -y --no-install-recommends"
+                                         " a_package another_package",
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(5, runner.calls)
+
+    def test_installed_packages_and_install_variant(self):
+        """ Only variant must be installed. Packages are already installed
+        """
+        packages = [("varianta", "variantb", "variantc"), "a_package", "another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(['dpkg-query -W -f=\'${Status}\' a_package | '
+                                         'grep -q "ok installed"',
+                                         'dpkg-query -W -f=\'${Status}\' another_package | '
+                                         'grep -q "ok installed"',
+                                         "sudo -A apt-get update",
+                                         "sudo -A apt-get install -y --no-install-recommends"
+                                         " varianta",
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(7, runner.calls)
+
+    def test_variants_and_packages_installed(self):
+        """ Install nothing, all is already installed
+        """
+        packages = [("varianta", "variantb", "variantc"), "a_package", "another_package"]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock(['dpkg-query -W -f=\'${Status}\' varianta | '
+                                         'grep -q "ok installed"',
+                                         'dpkg-query -W -f=\'${Status}\' a_package | '
+                                         'grep -q "ok installed"',
+                                         'dpkg-query -W -f=\'${Status}\' another_package | '
+                                         'grep -q "ok installed"',
+                                         ])
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(3, runner.calls)
+
+    def test_empty_variants_and_packages(self):
+        packages = [(),]
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            runner = RunnerMultipleMock()
+            spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out),
+                                    output=self.out)
+            spt.install_packages(packages)
+            self.assertEqual(0, runner.calls)
+
+    def test_install_all_multiple_package_list(self):
+        """ Separated string list must be treated as full package list to be installed
+        """
+        packages = "varianta variantb", "variantc variantd"
+        runner = RunnerMultipleMock([])
+        spt = SystemPackageTool(runner=runner, tool=AptTool(output=self.out), output=self.out)
+        with self.assertRaises(ConanException) as error:
+            spt.install_packages(packages)
+            self.assertEqual("Each string must contain only one package to be installed."
+                             " Use a list instead e.g. ['foo', 'bar'].", str(error.exception))