Repository: pypa/pipx (python)

Issue:
Allow `pipx upgrade` to accept multiple packages as arguments
**How would this feature be useful?**

The possibility to handle multiple package specs at once was already introduced to the `install` command in #1102. The logical next step would be to extend this functionality to the `pipx upgrade`, and probably also `pipx reinstall` commands.

**Describe the solution you'd like**

Implement it.

**Describe alternatives you've considered**

Alternatively, one could write shell script or other programming code wrappers to take over this functionality, which however is not exactly user-friendly nor efficient.

Pull request description:
feat: allow `upgrade` command to accept multiple packages as arguments
<!-- add an 'x' in the brackets below -->

- [x] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

## Summary of changes

Close #1336

## 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
nox -s tests
```


Patch:
diff --git a/changelog.d/1336.feature.md b/changelog.d/1336.feature.md
new file mode 100644
index 0000000..96dd3ba
--- /dev/null
+++ b/changelog.d/1336.feature.md
@@ -0,0 +1,1 @@
+Allow `upgrade` command to accept multiple packages as arguments.
diff --git a/src/pipx/commands/upgrade.py b/src/pipx/commands/upgrade.py
index d7f6caf..98049ea 100644
--- a/src/pipx/commands/upgrade.py
+++ b/src/pipx/commands/upgrade.py
@@ -1,7 +1,7 @@
 import logging
 import os
 from pathlib import Path
-from typing import List, Optional, Sequence
+from typing import Dict, List, Optional, Sequence
 
 from pipx import commands, paths
 from pipx.colors import bold, red
@@ -180,7 +180,7 @@ def _upgrade_venv(
 
 
 def upgrade(
-    venv_dir: Path,
+    venv_dirs: Dict[str, Path],
     python: Optional[str],
     pip_args: List[str],
     verbose: bool,
@@ -192,17 +192,18 @@ def upgrade(
 ) -> ExitCode:
     """Return pipx exit code."""
 
-    _ = _upgrade_venv(
-        venv_dir,
-        pip_args,
-        verbose,
-        include_injected=include_injected,
-        upgrading_all=False,
-        force=force,
-        install=install,
-        python=python,
-        python_flag_passed=python_flag_passed,
-    )
+    for venv_dir in venv_dirs.values():
+        _ = _upgrade_venv(
+            venv_dir,
+            pip_args,
+            verbose,
+            include_injected=include_injected,
+            upgrading_all=False,
+            force=force,
+            install=install,
+            python=python,
+            python_flag_passed=python_flag_passed,
+        )
 
     # Any error in upgrade will raise PipxError (e.g. from venv.upgrade_package())
     return EXIT_CODE_OK
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 23c3c35..cee9df3 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -195,6 +195,14 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
 
         venv_dir = venv_container.get_venv_dir(package)
         logger.info(f"Virtual Environment location is {venv_dir}")
+
+    if "packages" in args:
+        if any(urllib.parse.urlparse(package).scheme for package in args.packages):
+            raise PipxError("Package cannot be a url. Package name should be passed instead.")
+        venv_dirs = {package: venv_container.get_venv_dir(package) for package in args.packages}
+        venv_dirs_msg = "\n".join(f"- {key} : {value}" for key, value in venv_dirs.items())
+        logger.info(f"Virtual Environment locations are:\n{venv_dirs_msg}")
+
     if "skip" in args:
         skip_list = [canonicalize_name(x) for x in args.skip]
 
@@ -285,7 +293,7 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
         )
     elif args.command == "upgrade":
         return commands.upgrade(
-            venv_dir,
+            venv_dirs,
             args.python,
             pip_args,
             verbose,
@@ -531,10 +539,10 @@ def _add_upgrade(subparsers, venv_completer: VenvCompleter, shared_parser: argpa
     p = subparsers.add_parser(
         "upgrade",
         help="Upgrade a package",
-        description="Upgrade a package in a pipx-managed Virtual Environment by running 'pip install --upgrade PACKAGE'",
+        description="Upgrade package(s) in pipx-managed Virtual Environment(s) by running 'pip install --upgrade PACKAGE'",
         parents=[shared_parser],
     )
-    p.add_argument("package").completer = venv_completer
+    p.add_argument("packages", help="package names(s) to upgrade", nargs="+").completer = venv_completer
     p.add_argument(
         "--include-injected",
         action="store_true",


Test patch:
diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py
index 4892286..b9ca2a8 100644
--- a/tests/test_upgrade.py
+++ b/tests/test_upgrade.py
@@ -102,3 +102,17 @@ def test_upgrade_install_missing(pipx_temp_env, capsys):
     assert not run_pipx_cli(["upgrade", "pycowsay", "--install"])
     captured = capsys.readouterr()
     assert "installed package pycowsay" in captured.out
+
+
+def test_upgrade_multiple(pipx_temp_env, capsys):
+    name = "pylint"
+    pkg_spec = PKG[name]["spec"]
+    initial_version = pkg_spec.split("==")[-1]
+    assert not run_pipx_cli(["install", pkg_spec])
+
+    assert not run_pipx_cli(["install", "pycowsay"])
+
+    assert not run_pipx_cli(["upgrade", name, "pycowsay"])
+    captured = capsys.readouterr()
+    assert f"upgraded package {name} from {initial_version} to" in captured.out
+    assert "pycowsay is already at latest version" in captured.out