Repository: pipxproject/pipx
Pull request #491 (2020-08-31 19:33:29)

Improve support for suffixed packages
<!-- add an 'x' in the brackets below -->
* [x] I have added an entry to `docs/changelog.md`

## Summary of changes
This change allows for safe listing and upgrade of packages installed with a suffix. As part of this change, a new metadata property "suffix" has been introduced.

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```sh
pipx install --suffix=_test pycowsay
pipx upgrade pycowsay_test
pipx inject pycowsay_test pip
pipx list
pipx uninstall pycowsay_test
```


abn: @itsayellow is there any further changes required for this one? 
itsayellow: > @itsayellow are there any further changes required for this one?

Still working on it.  With unpaid volunteer labor you need to be patient. 🙂
abn: > Still working on it. With unpaid volunteer labor you need to be patient.

Well aware of those pains, was not intending to push for resolution. Just making sure to dot the i's and cross the t's. :) 
itsayellow: Oh my gosh, so currently it looks like parts of pipx are actually broken when using packages with a suffix?  I had no idea.  This is less of an "improvement" and more of a necessary bugfix!
cs01: @abn Thank you for the PR. I looked it over and it looks good to me -- I have no comments. It is rare to see no lint errors, unit tests passing AND new unit tests all in a single commit. Impressive! 

I'll wait for @itsayellow's review before merging since he wrote the metadata code and knows it better than me.
cs01: > Oh my gosh, so currently it looks like parts of pipx are actually broken when using packages with a suffix? I had no idea. This is less of an "improvement" and more of a necessary bugfix!

Yeah I was kind of surprised that these changes were needed, though I'm glad they were; they seem like basic features of the suffix functionality.
abn: @itsayellow ah yes, it is broken. I figured you were aware of this ... limitation of the current implementation. I recently started using pipx quite regularly and is finding the suffix feature rather useful for triage/testing the cli tool I help out with. That is what initiated the fix. :)

@cs01 I think I had a lint failire on my inital push ... so not all clean :wink: 
abn: > Let me know if there's something I'm missing and this isn't possible in all cases, but from my read I think what I said can be done.
@itsayellow  see my response for why I ended up doing that. TLDR; upgrade of injected packages was not working as expected.
itsayellow: @cs01, there's some policy questions here that I'm curious for your thoughts about.
My thoughts:
* Only one suffix per venv
* Only the main package and its apps get the suffix (not injected packages + their apps)
* Should `--include-deps` apps also get the suffix?
abn: Assuming that the included apps are exposed to path, I'd suggest we should allow suffix configuration there too. 

I do currently have a use case where a certain pip version is a dependency that gets injected and would like to expose multiple versions from different environments. But this suffix might differ from the main package suffix. I appreciate this is not in scope for the PR, but wanted to mention this for the policy decision. 
itsayellow: > I do currently have a use case where a certain pip version is a dependency that gets injected and would like to expose multiple versions from different environments. But this suffix might differ from the main package suffix. I appreciate this is not in scope for the PR, but wanted to mention this for the policy decision.

In this case, why would the pip suffix need to differ from the main package suffix?
itsayellow: My rationale for a simple policy regarding suffixes *right now*, is that it's already rippling through the code, and absorbing a simple pervasive change now and incrementally improving/expanding it later will be easier (and hopefully more successful) for implementation.  Also, a more complicated system of suffixes would be hard to explain/document from a user perspective.

I am sensitive to how useful a more powerful/expansive approach of suffixes could be, but there is a tradeoff between power and complexity.
cs01: > @cs01, there's some policy questions here that I'm curious for your thoughts about.
> My thoughts:
> 
> * Only one suffix per venv
> * Only the main package and its apps get the suffix (not injected packages + their apps)
> * Should `--include-deps` apps also get the suffix?

> Also, a more complicated system of suffixes would be hard to explain/document from a user perspective.

We need to consider first whether the feature can be understood and supported by part time volunteers, and second if a significant portions of users would use the feature.

pipx has been slowly accreting new features, and each new feature adds significant complexity. Between inject, `--include-deps`, and metadata tracking, the codebase has become much more complicated. pipx is, IMO, very useful as it is, and this suffix feature, although pretty cool, is a bit of a corner case.

We should not sacrifice maintainability of the codebase for a feature that is not essential, especially since the project is maintained by part time volunteers. This is what happened to pipenv; its complexity became overwhelming and the project suffered.  

At some point, users who want such specific features need to just make a venv and bash script instead of shoehorning it all into pipx. IMO the only way the suffix feature can work and pipx can remain viable is if the feature and implementation is kept extremely simple. I am fine not supporting suffixes at all, or only supporting them with functionality that is reduced but maintainable and easily documented. Maybe that's just me be being selfish because I'm perfectly happy with pipx as it is and don't want increased maintenance burden.

> * Only one suffix per venv
> * Only the main package and its apps get the suffix (not injected packages + their apps)

I'm fine with these limitations. 

> * Should `--include-deps` apps also get the suffix?

If `--include-deps` is used, and the same package was already installed with a different suffix, there will be name collisions if a suffix is not added to the apps of the dependent packages. So I think from a user perspective there has to be. I just wonder how hard this will be from a maintainer's perspective.

Maybe a simpler way to accomplish this and remove limitations is to symlink an entire folder with the suffix name, like `black_suffix` in the bin dir. Then users can run `black_suffix/black` from the command line.


itsayellow: What if we described suffixes as "experimental and subject to possible change" in both the help and docs?
cs01: > What if we described suffixes as "experimental and subject to possible change" in both the help and docs?

That sounds like a good idea since things are in flux.
abn: @cs01 just to add to the discussion, the suffix feature does not need to be complex. From my perspective, simply ensuring the main package is the only one that can be suffixed is good enough to reduce unnecessary complexity. 

The reason why I turned to `pipx` was this particular feature for what it is worth. This makes using multiple versions of python tools in the same environment a breeze. And I do sincerely hope that this feature remains, albeit with possible caveats.

itsayellow: > @cs01 just to add to the discussion, the suffix feature does not need to be complex. From my perspective, simply ensuring the main package is the only one that can be suffixed is good enough to reduce unnecessary complexity.
> 
> The reason why I turned to `pipx` was this particular feature for what it is worth. This makes using multiple versions of python tools in the same environment a breeze. And I do sincerely hope that this feature remains, albeit with possible caveats.

I think that's a good approach.

To summarize what @cs01 and I (and I believe @abn 🙂)  agree about:
* One suffix only per venv
* Suffix is applied to main package only
* Suffix is applied to apps of main package
* Suffix is applied to main package's dependencies' apps if `--include-deps` is used to install main package

Hopefully with this approach there is an implementation that keeps code complexity down.
itsayellow: As a minor side note:

> Maybe a simpler way to accomplish this and remove limitations is to symlink an entire folder with the suffix name, like `black_suffix` in the bin dir to the venv's bin dir. Then users can run `black_suffix/black` from the command line, and pipx doesn't have to do anything complicated other than create a venv and install to it.

Does this work?  I couldn't get `<subdirectory>/<app>` in a `PATH` directory to work for me in zsh.
cs01: > Does this work? I couldn't get `<subdirectory>/<app>` in a `PATH` directory to work for me in zsh.

I just tried it myself and it doesn't work. I assumed you could access folders on your PATH, but that doesn't seem to be the case.

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index f19b37a..be763ce 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,6 +1,7 @@
 dev
 
 - [docs] Update license
+- [bugfix] Fixed regression in list, inject and upgrade commands when suffixed packages are used.
 
 
 0.15.5.1
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index 24263d0..7c90c19 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -86,7 +86,8 @@ def _symlink_package_apps(
 ) -> None:
     for app_path in app_paths:
         app_name = app_path.name
-        symlink_path = Path(local_bin_dir / add_suffix(app_name, suffix))
+        app_name_suffixed = add_suffix(app_name, suffix)
+        symlink_path = Path(local_bin_dir / app_name_suffixed)
         if not symlink_path.parent.is_dir():
             mkdir(symlink_path.parent)
 
@@ -117,12 +118,12 @@ def _symlink_package_apps(
             )
             symlink_path.unlink()
 
-        existing_executable_on_path = which(app_name)
+        existing_executable_on_path = which(app_name_suffixed)
         symlink_path.symlink_to(app_path)
 
         if existing_executable_on_path:
             logging.warning(
-                f"{hazard}  Note: {app_name} was already on your PATH at "
+                f"{hazard}  Note: {app_name_suffixed} was already on your PATH at "
                 f"{existing_executable_on_path}"
             )
 
@@ -133,12 +134,13 @@ def get_package_summary(
     package: str = None,
     new_install: bool = False,
     include_injected: bool = False,
-    suffix: str = "",
 ) -> str:
     venv = Venv(path)
     python_path = venv.python_path.resolve()
-    if package is None:
-        package = path.name
+
+    package_metadata = venv.get_package_metadata(package)
+    package = package_metadata.package or venv.root.name
+
     if not python_path.is_file():
         return f"   package {red(bold(package))} has invalid interpreter {str(python_path)}"
     if not venv.package_metadata:
@@ -148,8 +150,6 @@ def get_package_summary(
             f"       Please uninstall and install this package, or reinstall-all to fix."
         )
 
-    package_metadata = venv.package_metadata[package]
-
     if package_metadata.package_version is None:
         not_installed = red("is not installed")
         return f"   package {bold(package)} {not_installed} in the venv {str(path)}"
@@ -160,7 +160,7 @@ def get_package_summary(
     )
     exposed_binary_names = sorted(p.name for p in exposed_app_paths)
     unavailable_binary_names = sorted(
-        {add_suffix(name, suffix) for name in package_metadata.apps}
+        {add_suffix(name, package_metadata.suffix) for name in package_metadata.apps}
         - set(exposed_binary_names)
     )
     # The following is to satisfy mypy that python_version is str and not
@@ -179,6 +179,7 @@ def get_package_summary(
         exposed_binary_names,
         unavailable_binary_names,
         venv.pipx_metadata.injected_packages if include_injected else None,
+        suffix=package_metadata.suffix,
     )
 
 
@@ -215,10 +216,13 @@ def _get_list_output(
     exposed_binary_names: List[str],
     unavailable_binary_names: List[str],
     injected_packages: Optional[Dict[str, PackageInfo]] = None,
+    suffix: str = "",
 ) -> str:
     output = []
+    suffix = f" ({bold(shlex.quote(package + suffix))})" if suffix else ""
     output.append(
-        f"  {'installed' if new_install else ''} package {bold(shlex.quote(package))} {bold(package_version)}, {python_version}"
+        f"  {'installed' if new_install else ''} package {bold(shlex.quote(package))}"
+        f" {bold(package_version)}{suffix}, {python_version}"
     )
 
     if not python_path.exists():
@@ -276,9 +280,10 @@ def run_post_install_actions(
     include_dependencies: bool,
     *,
     force: bool,
-    suffix: Optional[str] = None,
 ):
-    package_metadata = venv.package_metadata[package]
+    package_metadata = venv.get_package_metadata(package)
+    package = package_metadata.package or package
+    display_name = f"{package}{package_metadata.suffix}"
 
     if not package_metadata.app_paths and not include_dependencies:
         # No apps associated with this package and we aren't including dependencies.
@@ -295,7 +300,7 @@ def run_post_install_actions(
 
         if package_metadata.app_paths_of_dependencies:
             raise PipxError(
-                f"No apps associated with package {package}. "
+                f"No apps associated with package {display_name}. "
                 "Try again with '--include-deps' to include apps of dependent packages, "
                 "which are listed above. "
                 "If you are attempting to install a library, pipx should not be used. "
@@ -303,7 +308,7 @@ def run_post_install_actions(
             )
         else:
             raise PipxError(
-                f"No apps associated with package {package}. "
+                f"No apps associated with package {display_name}. "
                 "If you are attempting to install a library, pipx should not be used. "
                 "Consider using pip or a similar tool instead."
             )
@@ -318,26 +323,30 @@ def run_post_install_actions(
         if venv.safe_to_remove():
             venv.remove_venv()
         raise PipxError(
-            f"No apps associated with package {package} or its dependencies."
+            f"No apps associated with package {display_name} or its dependencies."
             "If you are attempting to install a library, pipx should not be used. "
             "Consider using pip or a similar tool instead."
         )
 
-    if suffix is None:
-        suffix = ""
     expose_apps_globally(
-        local_bin_dir, package_metadata.app_paths, package, force=force, suffix=suffix
+        local_bin_dir,
+        package_metadata.app_paths,
+        package,
+        force=force,
+        suffix=package_metadata.suffix,
     )
 
     if include_dependencies:
         for _, app_paths in package_metadata.app_paths_of_dependencies.items():
             expose_apps_globally(
-                local_bin_dir, app_paths, package, force=force, suffix=suffix
+                local_bin_dir,
+                app_paths,
+                package,
+                force=force,
+                suffix=package_metadata.suffix,
             )
 
-    print(
-        get_package_summary(venv_dir, package=package, new_install=True, suffix=suffix)
-    )
+    print(get_package_summary(venv_dir, package=package, new_install=True,))
     warn_if_not_on_path(local_bin_dir)
     print(f"done! {stars}", file=sys.stderr)
 
diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py
index 6d24dd0..d7e62b5 100644
--- a/src/pipx/commands/install.py
+++ b/src/pipx/commands/install.py
@@ -58,6 +58,7 @@ def install(
             include_dependencies=include_dependencies,
             include_apps=True,
             is_main_package=True,
+            suffix=suffix or "",
         )
         run_post_install_actions(
             venv,
@@ -66,7 +67,6 @@ def install(
             venv_dir,
             include_dependencies,
             force=force,
-            suffix=suffix,
         )
     except (Exception, KeyboardInterrupt):
         print("")
diff --git a/src/pipx/commands/upgrade.py b/src/pipx/commands/upgrade.py
index 5a695e8..84da927 100644
--- a/src/pipx/commands/upgrade.py
+++ b/src/pipx/commands/upgrade.py
@@ -39,7 +39,9 @@ def upgrade(
         )
         return 0
 
-    package_metadata = venv.package_metadata[package]
+    package_metadata = venv.get_package_metadata(package)
+    package = package_metadata.package or package
+
     if package_metadata.package_or_url is None:
         raise PipxError(f"Internal Error: package {package} has corrupt pipx metadata.")
 
@@ -61,20 +63,31 @@ def upgrade(
         include_dependencies=include_dependencies,
         include_apps=include_apps,
         is_main_package=True,
+        suffix=package_metadata.suffix,
     )
     # TODO 20191026: upgrade injected packages also (Issue #79)
 
-    package_metadata = venv.package_metadata[package]
+    package_metadata = venv.get_package_metadata(package)
+    package = package_metadata.package or package
+    display_name = f"{package_metadata.package}{package_metadata.suffix}"
     new_version = package_metadata.package_version
 
     expose_apps_globally(
-        constants.LOCAL_BIN_DIR, package_metadata.app_paths, package, force=force
+        constants.LOCAL_BIN_DIR,
+        package_metadata.app_paths,
+        package,
+        force=force,
+        suffix=package_metadata.suffix,
     )
 
     if include_dependencies:
         for _, app_paths in package_metadata.app_paths_of_dependencies.items():
             expose_apps_globally(
-                constants.LOCAL_BIN_DIR, app_paths, package, force=force
+                constants.LOCAL_BIN_DIR,
+                app_paths,
+                package,
+                force=force,
+                suffix=package_metadata.suffix,
             )
 
     if old_version == new_version:
@@ -82,12 +95,12 @@ def upgrade(
             pass
         else:
             print(
-                f"{package} is already at latest version {old_version} (location: {str(venv_dir)})"
+                f"{display_name} is already at latest version {old_version} (location: {str(venv_dir)})"
             )
         return 0
     else:
         print(
-            f"upgraded package {package} from {old_version} to {new_version} (location: {str(venv_dir)})"
+            f"upgraded package {display_name} from {old_version} to {new_version} (location: {str(venv_dir)})"
         )
         return 1
 
diff --git a/src/pipx/pipx_metadata_file.py b/src/pipx/pipx_metadata_file.py
index 64526c3..0cb8de3 100644
--- a/src/pipx/pipx_metadata_file.py
+++ b/src/pipx/pipx_metadata_file.py
@@ -35,9 +35,13 @@ class PackageInfo(NamedTuple):
     apps_of_dependencies: List[str]
     app_paths_of_dependencies: Dict[str, List[Path]]
     package_version: str
+    suffix: str = ""
 
 
 class PipxMetadata:
+    # Only change this if file format changes
+    __METADATA_VERSION__: str = "0.2"
+
     def __init__(self, venv_dir: Path, read: bool = True):
         self.venv_dir = venv_dir
         # We init this instance with reasonable fallback defaults for all
@@ -61,9 +65,6 @@ class PipxMetadata:
         self.venv_args: List[str] = []
         self.injected_packages: Dict[str, PackageInfo] = {}
 
-        # Only change this if file format changes
-        self._pipx_metadata_version: str = "0.1"
-
         if read:
             self.read()
 
@@ -96,15 +97,22 @@ class PipxMetadata:
             "injected_packages": {
                 name: data._asdict() for (name, data) in self.injected_packages.items()
             },
-            "pipx_metadata_version": self._pipx_metadata_version,
+            "pipx_metadata_version": self.__METADATA_VERSION__,
         }
 
     def from_dict(self, input_dict: Dict[str, Any]) -> None:
-        self.main_package = PackageInfo(**input_dict["main_package"])
+        main_package_data = input_dict["main_package"]
+        if main_package_data["package"] != self.venv_dir.name:
+            # handle older suffixed packages gracefully
+            main_package_data["suffix"] = self.venv_dir.name.replace(
+                main_package_data["package"], ""
+            )
+
+        self.main_package = PackageInfo(**main_package_data)
         self.python_version = input_dict["python_version"]
         self.venv_args = input_dict["venv_args"]
         self.injected_packages = {
-            name: PackageInfo(**data)
+            f"{name}{data.get('suffix', '')}": PackageInfo(**data)
             for (name, data) in input_dict["injected_packages"].items()
         }
 
diff --git a/src/pipx/venv.py b/src/pipx/venv.py
index eeae8a7..930497f 100644
--- a/src/pipx/venv.py
+++ b/src/pipx/venv.py
@@ -2,7 +2,7 @@ import json
 import logging
 import pkgutil
 from pathlib import Path
-from typing import Dict, Generator, List, NamedTuple, Set
+from typing import Dict, Generator, List, NamedTuple, Optional, Set
 
 from pipx.animate import animate
 from pipx.constants import PIPX_SHARED_PTH
@@ -121,6 +121,18 @@ class Venv:
             ] = self.pipx_metadata.main_package
         return return_dict
 
+    def get_package_metadata(self, package: Optional[str] = None) -> PackageInfo:
+        if package is None or package == self.root.name:
+            return self.pipx_metadata.main_package
+
+        try:
+            return self.package_metadata[package]
+        except KeyError:
+            raise PipxError(
+                f"Package is not installed. Expected to find package {package}, "
+                "but it does not exist."
+            )
+
     def create_venv(self, venv_args: List[str], pip_args: List[str]) -> None:
         with animate("creating virtual environment", self.do_animation):
             cmd = [self.python, "-m", "venv", "--without-pip"]
@@ -169,6 +181,7 @@ class Venv:
         include_dependencies: bool,
         include_apps: bool,
         is_main_package: bool,
+        suffix: str = "",
     ) -> None:
         if pip_args is None:
             pip_args = []
@@ -199,6 +212,7 @@ class Venv:
             include_dependencies=include_dependencies,
             include_apps=include_apps,
             is_main_package=is_main_package,
+            suffix=suffix,
         )
 
         # Verify package installed ok
@@ -285,6 +299,7 @@ class Venv:
         include_dependencies: bool,
         include_apps: bool,
         is_main_package: bool,
+        suffix: str = "",
     ) -> None:
         venv_package_metadata = self.get_venv_metadata_for_package(package)
         package_info = PackageInfo(
@@ -298,6 +313,7 @@ class Venv:
             apps_of_dependencies=venv_package_metadata.apps_of_dependencies,
             app_paths_of_dependencies=venv_package_metadata.app_paths_of_dependencies,
             package_version=venv_package_metadata.package_version,
+            suffix=suffix or "",
         )
         if is_main_package:
             self.pipx_metadata.main_package = package_info
@@ -345,6 +361,7 @@ class Venv:
         include_dependencies: bool,
         include_apps: bool,
         is_main_package: bool,
+        suffix: str = "",
     ) -> None:
         with animate(
             f"upgrading {full_package_description(package, package_or_url)}",
@@ -359,6 +376,7 @@ class Venv:
             include_dependencies=include_dependencies,
             include_apps=include_apps,
             is_main_package=is_main_package,
+            suffix=suffix or "",
         )
 
     def _run_pip(self, cmd: List[str]) -> int:


Test patch:
diff --git a/tests/test_inject.py b/tests/test_inject.py
index 4234aef..399eeb0 100644
--- a/tests/test_inject.py
+++ b/tests/test_inject.py
@@ -1,3 +1,5 @@
+import pytest  # type: ignore
+
 from helpers import run_pipx_cli
 
 
@@ -11,9 +13,23 @@ def test_spec(pipx_temp_env, capsys):
     assert not run_pipx_cli(["inject", "pycowsay", "pylint==2.3.1"])
 
 
-def test_include_apps(pipx_temp_env, capsys):
-    assert not run_pipx_cli(["install", "pycowsay"])
-    assert run_pipx_cli(["inject", "pycowsay", "black", "--include-deps"])
+@pytest.mark.parametrize("with_suffix,", [(False,), (True,)])
+def test_include_apps(pipx_temp_env, capsys, with_suffix):
+    install_args = []
+    suffix = ""
+
+    if with_suffix:
+        suffix = "_x"
+        install_args = [f"--suffix={suffix}"]
+
+    assert not run_pipx_cli(["install", "pycowsay", *install_args])
+    assert run_pipx_cli(["inject", f"pycowsay{suffix}", "black", "--include-deps"])
+
+    if suffix:
+        assert run_pipx_cli(
+            ["inject", "pycowsay", "black", "--include-deps", "--include-apps"]
+        )
+
     assert not run_pipx_cli(
-        ["inject", "pycowsay", "black", "--include-deps", "--include-apps"]
+        ["inject", f"pycowsay{suffix}", "black", "--include-deps", "--include-apps"]
     )
diff --git a/tests/test_list.py b/tests/test_list.py
index f2df23f..3096b00 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -22,3 +22,12 @@ def test_missing_interpreter(pipx_temp_env, monkeypatch, capsys):
     assert not run_pipx_cli(["list"])
     captured = capsys.readouterr()
     assert "package pycowsay has invalid interpreter" in captured.out
+
+
+def test_list_suffix(pipx_temp_env, monkeypatch, capsys):
+    suffix = "_x"
+    assert not run_pipx_cli(["install", "pycowsay", f"--suffix={suffix}"])
+    assert not run_pipx_cli(["list"])
+
+    captured = capsys.readouterr()
+    assert f"package pycowsay 0.0.0.1 (pycowsay{suffix})," in captured.out
diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py
index 4af3578..3cd194e 100644
--- a/tests/test_pipx_metadata_file.py
+++ b/tests/test_pipx_metadata_file.py
@@ -63,14 +63,17 @@ BLACK_PACKAGE_REF = PackageInfo(
 
 
 def test_pipx_metadata_file_create(tmp_path):
-    pipx_metadata = PipxMetadata(tmp_path)
+    venv_dir = tmp_path / TEST_PACKAGE1.package
+    venv_dir.mkdir()
+
+    pipx_metadata = PipxMetadata(venv_dir)
     pipx_metadata.main_package = TEST_PACKAGE1
     pipx_metadata.python_version = "3.4.5"
     pipx_metadata.venv_args = ["--system-site-packages"]
     pipx_metadata.injected_packages = {"injected": TEST_PACKAGE2}
     pipx_metadata.write()
 
-    pipx_metadata2 = PipxMetadata(tmp_path)
+    pipx_metadata2 = PipxMetadata(venv_dir)
 
     for attribute in [
         "venv_dir",
diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py
index cc606fe..1d4aea2 100644
--- a/tests/test_upgrade.py
+++ b/tests/test_upgrade.py
@@ -5,3 +5,12 @@ def test_upgrade(pipx_temp_env, capsys):
     assert run_pipx_cli(["upgrade", "pycowsay"])
     assert not run_pipx_cli(["install", "pycowsay"])
     assert not run_pipx_cli(["upgrade", "pycowsay"])
+
+
+def test_upgrade_suffix(pipx_temp_env, capsys):
+    name = "pycowsay"
+    suffix = "_a"
+
+    assert not run_pipx_cli(["install", name, f"--suffix={suffix}"])
+    assert run_pipx_cli(["upgrade", f"{name}"])
+    assert not run_pipx_cli(["upgrade", f"{name}{suffix}"])