Repository: pypa/pipx
Pull request #1153 (2023-12-18 12:02:42)

Revert "Bump actions/upload-artifact from 3 to 4 (#1147)"
<!-- add an 'x' in the brackets below -->

- [ ] I have added an entry to `docs/changelog.md`

## Summary of changes
This reverts commit 8822a0ba1d3b02369a5228555a1a246bc31f3176.
Ref: https://github.com/pypa/pipx/pull/1147#discussion_r1429431139

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Tested by running

```
# command(s) to exercise these changes
```



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d584647..8876126 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@ repos:
   hooks:
     - id: pyproject-fmt
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.1.7
+  rev: v0.1.8
   hooks:
   - id: ruff-format
   - id: ruff
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/.github/workflows/create_tests_package_lists.yml b/.github/workflows/create_tests_package_lists.yml
index b294a6c..8e4edf3 100644
--- a/.github/workflows/create_tests_package_lists.yml
+++ b/.github/workflows/create_tests_package_lists.yml
@@ -30,7 +30,7 @@ jobs:
         run:
           nox --non-interactive --session create_test_package_list-${{ matrix.python-version }} -- ./new_tests_packages
       - name: Store reports as artifacts
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v3
         with:
           name: lists
           path: ./new_tests_packages
diff --git a/.github/workflows/exhaustive_package_test.yml b/.github/workflows/exhaustive_package_test.yml
index fb3e491..9fa850c 100644
--- a/.github/workflows/exhaustive_package_test.yml
+++ b/.github/workflows/exhaustive_package_test.yml
@@ -27,7 +27,7 @@ jobs:
         continue-on-error: true
         run: nox --non-interactive --session test_all_packages-${{ matrix.python-version }}
       - name: Store reports as artifacts
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v3
         with:
           name: reports-raw
           path: reports
@@ -39,7 +39,7 @@ jobs:
 
     steps:
       - name: Get report artifacts
-        uses: actions/download-artifact@v4
+        uses: actions/download-artifact@v3
         with:
           name: reports-raw
       - name: Collate reports
@@ -57,7 +57,7 @@ jobs:
           cat all_packages_deps_errors_* > all_deps_errors_lf.txt
           tr -d '\r' < all_deps_errors_lf.txt > reports/all_deps_errors.txt
       - name: Store collated and raw reports as artifacts
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v3
         with:
           name: reports-final
           path: reports
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 89689d9..e42b388 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -77,7 +77,7 @@ jobs:
         run: nox --error-on-missing-interpreters --non-interactive --session zipapp
       - name: Test zipapp by installing black
         run: python ./pipx.pyz install black
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v3
         with:
           name: pipx.pyz
           path: pipx.pyz
@@ -114,7 +114,7 @@ jobs:
     needs: [tests, man, zipapp]
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/download-artifact@v4
+      - uses: actions/download-artifact@v3
         with:
           name: pipx.pyz
       - name: Upload to release
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):