Repository: pypa/pipx (python)

Issue:
Allow installing multiple applications with `install` command
**How would this feature be useful?**
Instead of:
```
$pip = @("internetarchive", "git+https://github.com/arecarn/dploy.git", "tubeup")
foreach ($p in $pip) {pipx install $p}
```
I will be able to do just `pipx install internetarchive git+https://github.com/arecarn/dploy.git tubeup`

**Describe the solution you'd like**
Allow installing multiple apps with `pipx install`

**Describe alternatives you've considered**
Using foreach or something like this.


Pull request description:
Allow installing multiple applications at once
<!-- add an 'x' in the brackets below -->
* [x] I have added an entry to `docs/changelog.md`

## Summary of changes
As a response to issue #971, the `commands.install` function is modified to take lists of package names/specs as arguments, and iterate through the lists, installing each application separately. The associated CLI functions and help strings are updated to reflect this. The `commands.reinstall` function is also modified to ensure it complies with the new argument requirements of `commands.install`.  Two tests have been added (replicating `test_install_easy_packages` and `test_install_tricky_packages`, but doing multiple packages at once).

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by manually running
```
pipx install pycowsay black
```
and also with the new unit tests.

Resolves #971

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a693836..e2f546b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 ## dev
 
 - [docs] Add Scoop installation instructions
+- Add ability to install multiple packages at once
 
 ## 1.3.3
 
diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py
index 66cfac1..8e84bd3 100644
--- a/src/pipx/commands/install.py
+++ b/src/pipx/commands/install.py
@@ -11,8 +11,8 @@ from pipx.venv import Venv, VenvContainer
 
 def install(
     venv_dir: Optional[Path],
-    package_name: Optional[str],
-    package_spec: str,
+    package_names: Optional[List[str]],
+    package_specs: List[str],
     local_bin_dir: Path,
     local_man_dir: Path,
     python: Optional[str],
@@ -33,73 +33,82 @@ def install(
 
     python = python or DEFAULT_PYTHON
 
-    if package_name is None:
-        package_name = package_name_from_spec(package_spec, python, pip_args=pip_args, verbose=verbose)
-    if venv_dir is None:
-        venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
-        venv_dir = venv_container.get_venv_dir(f"{package_name}{suffix}")
+    package_names = package_names or []
+    if len(package_names) != len(package_specs):
+        package_names = [
+            package_name_from_spec(package_spec, python, pip_args=pip_args, verbose=verbose)
+            for package_spec in package_specs
+        ]
 
-    try:
-        exists = venv_dir.exists() and bool(next(venv_dir.iterdir()))
-    except StopIteration:
-        exists = False
+    for package_name, package_spec in zip(package_names, package_specs):
+        if venv_dir is None:
+            venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
+            venv_dir = venv_container.get_venv_dir(f"{package_name}{suffix}")
 
-    venv = Venv(venv_dir, python=python, verbose=verbose)
-    if exists:
-        if not reinstall and force and python_flag_was_passed:
-            print(
-                pipx_wrap(
-                    f"""
-                    --python is ignored when --force is passed.
-                    If you want to reinstall {package_name} with {python},
-                    run `pipx reinstall {package_spec} --python {python}` instead.
-                    """
+        try:
+            exists = venv_dir.exists() and bool(next(venv_dir.iterdir()))
+        except StopIteration:
+            exists = False
+
+        venv = Venv(venv_dir, python=python, verbose=verbose)
+        if exists:
+            if not reinstall and force and python_flag_was_passed:
+                print(
+                    pipx_wrap(
+                        f"""
+                        --python is ignored when --force is passed.
+                        If you want to reinstall {package_name} with {python},
+                        run `pipx reinstall {package_spec} --python {python}` instead.
+                        """
+                    )
                 )
-            )
-        if force:
-            print(f"Installing to existing venv {venv.name!r}")
-            pip_args = ["--force-reinstall"] + pip_args
-        else:
-            print(
-                pipx_wrap(
-                    f"""
-                    {venv.name!r} already seems to be installed. Not modifying
-                    existing installation in '{venv_dir}'. Pass '--force'
-                    to force installation.
-                    """
+            if force:
+                print(f"Installing to existing venv {venv.name!r}")
+                pip_args = ["--force-reinstall"] + pip_args
+            else:
+                print(
+                    pipx_wrap(
+                        f"""
+                        {venv.name!r} already seems to be installed. Not modifying
+                        existing installation in '{venv_dir}'. Pass '--force'
+                        to force installation.
+                        """
+                    )
                 )
+                return EXIT_CODE_INSTALL_VENV_EXISTS
+
+        try:
+            # Enable installing shared library `pip` with `pipx`
+            override_shared = package_name == "pip"
+            venv.create_venv(venv_args, pip_args, override_shared)
+            for dep in preinstall_packages or []:
+                dep_name = package_name_from_spec(dep, python, pip_args=pip_args, verbose=verbose)
+                venv.upgrade_package_no_metadata(dep_name, [])
+            venv.install_package(
+                package_name=package_name,
+                package_or_url=package_spec,
+                pip_args=pip_args,
+                include_dependencies=include_dependencies,
+                include_apps=True,
+                is_main_package=True,
+                suffix=suffix,
+            )
+            run_post_install_actions(
+                venv,
+                package_name,
+                local_bin_dir,
+                local_man_dir,
+                venv_dir,
+                include_dependencies,
+                force=force,
             )
-            return EXIT_CODE_INSTALL_VENV_EXISTS
+        except (Exception, KeyboardInterrupt):
+            print()
+            venv.remove_venv()
+            raise
 
-    try:
-        # Enable installing shared library `pip` with `pipx`
-        override_shared = package_name == "pip"
-        venv.create_venv(venv_args, pip_args, override_shared)
-        for dep in preinstall_packages or []:
-            dep_name = package_name_from_spec(dep, python, pip_args=pip_args, verbose=verbose)
-            venv.upgrade_package_no_metadata(dep_name, [])
-        venv.install_package(
-            package_name=package_name,
-            package_or_url=package_spec,
-            pip_args=pip_args,
-            include_dependencies=include_dependencies,
-            include_apps=True,
-            is_main_package=True,
-            suffix=suffix,
-        )
-        run_post_install_actions(
-            venv,
-            package_name,
-            local_bin_dir,
-            local_man_dir,
-            venv_dir,
-            include_dependencies,
-            force=force,
-        )
-    except (Exception, KeyboardInterrupt):
-        print()
-        venv.remove_venv()
-        raise
+        # Reset venv_dir to None ready to install the next package in the list
+        venv_dir = None
 
     # Any failure to install will raise PipxError, otherwise success
     return EXIT_CODE_OK
diff --git a/src/pipx/commands/reinstall.py b/src/pipx/commands/reinstall.py
index 7d5ec49..743ea5a 100644
--- a/src/pipx/commands/reinstall.py
+++ b/src/pipx/commands/reinstall.py
@@ -58,8 +58,8 @@ def reinstall(
     # install main package first
     install(
         venv_dir,
-        venv.main_package_name,
-        package_or_url,
+        [venv.main_package_name],
+        [package_or_url],
         local_bin_dir,
         local_man_dir,
         python,
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 36211f7..9b0fde4 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -103,7 +103,7 @@ INSTALL_DESCRIPTION = textwrap.dedent(
     dependency version conflicts or interfere with your OS's python
     packages. 'sudo' is not required to do this.
 
-    pipx install PACKAGE_NAME
+    pipx install PACKAGE_NAME ...
     pipx install --python PYTHON PACKAGE_NAME
     pipx install VCS_URL
     pipx install ./LOCAL_PATH
@@ -334,7 +334,7 @@ def _add_install(subparsers: argparse._SubParsersAction) -> None:
         formatter_class=LineWrapRawTextHelpFormatter,
         description=INSTALL_DESCRIPTION,
     )
-    p.add_argument("package_spec", help="package name or pip installation spec")
+    p.add_argument("package_spec", help="package name(s) or pip installation spec(s)", nargs="*")
     add_include_dependencies(p)
     p.add_argument("--verbose", action="store_true")
     p.add_argument(


Test patch:
diff --git a/tests/test_install.py b/tests/test_install.py
index 429e136..b814b73 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -22,13 +22,14 @@ def test_help_text(monkeypatch, capsys):
     assert "apps you can run from anywhere" in captured.out
 
 
-def install_package(capsys, pipx_temp_env, caplog, package, package_name=""):
-    if not package_name:
-        package_name = package
+def install_packages(capsys, pipx_temp_env, caplog, packages, package_names=()):
+    if len(package_names) != len(packages):
+        package_names = packages
 
-    run_pipx_cli(["install", package, "--verbose"])
+    run_pipx_cli(["install", *packages, "--verbose"])
     captured = capsys.readouterr()
-    assert f"installed package {package_name}" in captured.out
+    for package_name in package_names:
+        assert f"installed package {package_name}" in captured.out
     if not sys.platform.startswith("win"):
         # TODO assert on windows too
         # https://github.com/pypa/pipx/issues/217
@@ -44,7 +45,17 @@ def install_package(capsys, pipx_temp_env, caplog, package, package_name=""):
     [("pycowsay", "pycowsay"), ("black", PKG["black"]["spec"])],
 )
 def test_install_easy_packages(capsys, pipx_temp_env, caplog, package_name, package_spec):
-    install_package(capsys, pipx_temp_env, caplog, package_spec, package_name)
+    install_packages(capsys, pipx_temp_env, caplog, [package_spec], [package_name])
+
+
+def test_install_easy_multiple_packages(capsys, pipx_temp_env, caplog):
+    install_packages(
+        capsys,
+        pipx_temp_env,
+        caplog,
+        ["pycowsay", PKG["black"]["spec"]],
+        ["pycowsay", "black"],
+    )
 
 
 @pytest.mark.parametrize(
@@ -62,7 +73,17 @@ def test_install_tricky_packages(capsys, pipx_temp_env, caplog, package_name, pa
     if sys.platform.startswith("win") and package_name == "ansible":
         pytest.skip("Ansible is not installable on Windows")
 
-    install_package(capsys, pipx_temp_env, caplog, package_spec, package_name)
+    install_packages(capsys, pipx_temp_env, caplog, [package_spec], [package_name])
+
+
+def test_install_tricky_multiple_packages(capsys, pipx_temp_env, caplog):
+    if os.getenv("FAST"):
+        pytest.skip("skipping slow tests")
+
+    packages = ["cloudtoken", "awscli", "shell-functools"]
+    package_specs = [PKG[package]["spec"] for package in packages]
+
+    install_packages(capsys, pipx_temp_env, caplog, package_specs, packages)
 
 
 @pytest.mark.parametrize(
@@ -74,7 +95,7 @@ def test_install_tricky_packages(capsys, pipx_temp_env, caplog, package_name, pa
     ],
 )
 def test_install_package_specs(capsys, pipx_temp_env, caplog, package_name, package_spec):
-    install_package(capsys, pipx_temp_env, caplog, package_spec, package_name)
+    install_packages(capsys, pipx_temp_env, caplog, [package_spec], [package_name])
 
 
 def test_force_install(pipx_temp_env, capsys):
@@ -119,7 +140,7 @@ def test_include_deps(pipx_temp_env, capsys):
     ],
 )
 def test_name_tricky_characters(caplog, capsys, pipx_temp_env, package_name, package_spec):
-    install_package(capsys, pipx_temp_env, caplog, package_spec, package_name)
+    install_packages(capsys, pipx_temp_env, caplog, [package_spec], [package_name])
 
 
 def test_extra(pipx_temp_env, capsys):