Repository: pipxproject/pipx
Pull request #660 (2021-03-27 23:42:32)

pipx list json format
<!-- add an 'x' in the brackets below -->
* [x] I have added an entry to `docs/changelog.md`

## Summary of changes
The main purpose of this is to address the following issues: #627 , #390 , #109 .  It adds a `--json` switch to `pipx list` which directs `pipx list` to output rich information about all the venvs in json format.  The output is suitable to be directed to a file, e.g. `pipx list --json > pipx.json`.

The implementation basically dumps the contents of every venv's pipx_metadata.json file into an object that is then dumped into the output.  As such, there is probably more information than is strictly necessary to reinstall these packages.

The json produced follows the following structure:
```
{
    "pipx_spec_version": "0.1",
    "venvs": {
        "<venv_name1>": {
            "metadata": <contents of pipx_metadata.json for venv_name1>
        },
        "<venv_name2>": {
            "metadata": <contents of pipx_metadata.json for venv_name2>
        },
        [...]
    }
}
```

Here is an example of a sample pipx listing, both with `--json` and without, for pipx-installed `pycowsay`, and `black` with injected `pylint`:

<details>

```shell
>  pipx list --json
{
    "pipx_spec_version": "0.1",
    "venvs": {
        "black": {
            "metadata": {
                "injected_packages": {
                    "pylint": {
                        "app_paths": [
                            {
                                "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/epylint",
                                "__type__": "Path"
                            },
                            {
                                "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/pylint",
                                "__type__": "Path"
                            },
                            {
                                "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/pyreverse",
                                "__type__": "Path"
                            },
                            {
                                "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/symilar",
                                "__type__": "Path"
                            }
                        ],
                        "app_paths_of_dependencies": {
                            "isort": [
                                {
                                    "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/isort",
                                    "__type__": "Path"
                                },
                                {
                                    "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/isort-identify-imports",
                                    "__type__": "Path"
                                }
                            ]
                        },
                        "apps": [
                            "epylint",
                            "pylint",
                            "pyreverse",
                            "symilar"
                        ],
                        "apps_of_dependencies": [
                            "isort",
                            "isort-identify-imports"
                        ],
                        "include_apps": false,
                        "include_dependencies": false,
                        "package": "pylint",
                        "package_or_url": "pylint",
                        "package_version": "2.7.2",
                        "pip_args": [],
                        "suffix": ""
                    }
                },
                "main_package": {
                    "app_paths": [
                        {
                            "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/black",
                            "__type__": "Path"
                        },
                        {
                            "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/black-primer",
                            "__type__": "Path"
                        },
                        {
                            "__Path__": "/Users/mclapp/.local/pipx/venvs/black/bin/blackd",
                            "__type__": "Path"
                        }
                    ],
                    "app_paths_of_dependencies": {},
                    "apps": [
                        "black",
                        "black-primer",
                        "blackd"
                    ],
                    "apps_of_dependencies": [],
                    "include_apps": true,
                    "include_dependencies": false,
                    "package": "black",
                    "package_or_url": "black",
                    "package_version": "20.8b1",
                    "pip_args": [],
                    "suffix": ""
                },
                "pipx_metadata_version": "0.2",
                "python_version": "Python 3.9.2",
                "venv_args": []
            }
        },
        "pycowsay": {
            "metadata": {
                "injected_packages": {},
                "main_package": {
                    "app_paths": [
                        {
                            "__Path__": "/Users/mclapp/.local/pipx/venvs/pycowsay/bin/pycowsay",
                            "__type__": "Path"
                        }
                    ],
                    "app_paths_of_dependencies": {},
                    "apps": [
                        "pycowsay"
                    ],
                    "apps_of_dependencies": [],
                    "include_apps": true,
                    "include_dependencies": false,
                    "package": "pycowsay",
                    "package_or_url": "pycowsay",
                    "package_version": "0.0.0.1",
                    "pip_args": [],
                    "suffix": ""
                },
                "pipx_metadata_version": "0.2",
                "python_version": "Python 3.9.2",
                "venv_args": []
            }
        }
    }
}
> pipx list --include-injected
venvs are in /Users/mclapp/.local/pipx/venvs
apps are exposed on your $PATH at /Users/mclapp/.local/bin
   package black 20.8b1, Python 3.9.2
    - black
    - black-primer
    - blackd
    Injected Packages:
      - pylint 2.7.2
   package pycowsay 0.0.0.1, Python 3.9.2
    - pycowsay
```

</details>

Additionally I factored out the code that checks for venv problems into `venv_health_check()`.

## Test plan
<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->
Tested by running
```
pipx list --json
```


itsayellow: I'll add tests for this if everybody think the approach is good.
itsayellow: Looks like everybody is busy 😉  @cs01, does this seem like a basically good idea?  Any thoughts?
cs01: Sorry. Yes this looks good. Thanks for adding this. I have been thinking about it for a long time but never did it. You made it happen though 👍

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 9900fa5..6dfbe1c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -4,6 +4,7 @@ dev
 - Fixed issue which made pipx incorrectly list apps as part of a venv when they were not installed by pipx. (#650)
 - Fixed old regression that would prevent pipx uninstall from cleaning up linked binaries if the venv was old and did not have pipx metadata. (#651)
 - Fixed bugs with suffixed-venvs on Windows.  Now properly summarizes install, and actually uninstalls associated binaries for suffixed-venvs. (#653)
+- Add `--json` switch to `pipx list` to output rich json-metadata for all venvs.
 
 0.16.1.0
 
diff --git a/src/pipx/commands/common.py b/src/pipx/commands/common.py
index 349e613..c53cb4c 100644
--- a/src/pipx/commands/common.py
+++ b/src/pipx/commands/common.py
@@ -156,14 +156,10 @@ def _symlink_package_apps(
             )
 
 
-def get_venv_summary(
-    venv_dir: Path,
-    *,
-    package_name: Optional[str] = None,
-    new_install: bool = False,
-    include_injected: bool = False,
-) -> Tuple[str, VenvProblems]:
-    venv = Venv(venv_dir)
+def venv_health_check(
+    venv: Venv, package_name: Optional[str] = None
+) -> Tuple[VenvProblems, str]:
+    venv_dir = venv.root
     python_path = venv.python_path.resolve()
 
     if package_name is None:
@@ -171,29 +167,45 @@ def get_venv_summary(
 
     if not python_path.is_file():
         return (
-            f"   package {red(bold(venv_dir.name))} has invalid interpreter {str(python_path)}",
             VenvProblems(invalid_interpreter=True),
+            f"   package {red(bold(venv_dir.name))} has invalid interpreter {str(python_path)}",
         )
     if not venv.package_metadata:
         return (
-            f"   package {red(bold(venv_dir.name))} has missing internal pipx metadata.",
             VenvProblems(missing_metadata=True),
+            f"   package {red(bold(venv_dir.name))} has missing internal pipx metadata.",
         )
     if venv_dir.name != canonicalize_name(venv_dir.name):
         return (
-            f"   package {red(bold(venv_dir.name))} needs its internal data updated.",
             VenvProblems(bad_venv_name=True),
+            f"   package {red(bold(venv_dir.name))} needs its internal data updated.",
         )
-
-    package_metadata = venv.package_metadata[package_name]
-
-    if package_metadata.package_version == "":
+    if venv.package_metadata[package_name].package_version == "":
         return (
+            VenvProblems(not_installed=True),
             f"   package {red(bold(package_name))} {red('is not installed')} "
             f"in the venv {venv_dir.name}",
-            VenvProblems(not_installed=True),
         )
+    return (VenvProblems(), "")
+
 
+def get_venv_summary(
+    venv_dir: Path,
+    *,
+    package_name: Optional[str] = None,
+    new_install: bool = False,
+    include_injected: bool = False,
+) -> Tuple[str, VenvProblems]:
+    venv = Venv(venv_dir)
+
+    if package_name is None:
+        package_name = venv.main_package_name
+
+    (venv_problems, warning_message) = venv_health_check(venv, package_name)
+    if venv_problems.any_():
+        return (warning_message, venv_problems)
+
+    package_metadata = venv.package_metadata[package_name]
     apps = package_metadata.apps
     if package_metadata.include_dependencies:
         apps += package_metadata.apps_of_dependencies
@@ -226,7 +238,7 @@ def get_venv_summary(
             venv.pipx_metadata.injected_packages if include_injected else None,
             suffix=package_metadata.suffix,
         ),
-        VenvProblems(),
+        venv_problems,
     )
 
 
diff --git a/src/pipx/commands/list_packages.py b/src/pipx/commands/list_packages.py
index 3698e39..1b72631 100644
--- a/src/pipx/commands/list_packages.py
+++ b/src/pipx/commands/list_packages.py
@@ -1,25 +1,34 @@
+import json
+import sys
 from pathlib import Path
-from typing import Collection
+from typing import Any, Collection, Dict, Tuple
 
 from pipx import constants
 from pipx.colors import bold
-from pipx.commands.common import VenvProblems, get_venv_summary
+from pipx.commands.common import VenvProblems, get_venv_summary, venv_health_check
 from pipx.constants import EXIT_CODE_LIST_PROBLEM, EXIT_CODE_OK, ExitCode
 from pipx.emojis import sleep
-from pipx.venv import VenvContainer
+from pipx.pipx_metadata_file import JsonEncoderHandlesPath, PipxMetadata
+from pipx.venv import Venv, VenvContainer
 
+PIPX_SPEC_VERSION = "0.1"
 
-def list_packages(venv_container: VenvContainer, include_injected: bool) -> ExitCode:
-    """Returns pipx exit code."""
-    venv_dirs: Collection[Path] = sorted(venv_container.iter_venv_dirs())
-    if not venv_dirs:
-        print(f"nothing has been installed with pipx {sleep}")
-        return EXIT_CODE_OK
 
-    print(f"venvs are in {bold(str(venv_container))}")
-    print(f"apps are exposed on your $PATH at {bold(str(constants.LOCAL_BIN_DIR))}")
+def get_venv_metadata_summary(venv_dir: Path) -> Tuple[PipxMetadata, VenvProblems, str]:
+    venv = Venv(venv_dir)
 
-    venv_container.verify_shared_libs()
+    (venv_problems, warning_message) = venv_health_check(venv)
+    if venv_problems.any_():
+        return (PipxMetadata(venv_dir, read=False), venv_problems, warning_message)
+
+    return (venv.pipx_metadata, venv_problems, "")
+
+
+def list_text(
+    venv_dirs: Collection[Path], include_injected: bool, venv_root_dir: str
+) -> VenvProblems:
+    print(f"venvs are in {bold(venv_root_dir)}")
+    print(f"apps are exposed on your $PATH at {bold(str(constants.LOCAL_BIN_DIR))}")
 
     all_venv_problems = VenvProblems()
     for venv_dir in venv_dirs:
@@ -29,6 +38,53 @@ def list_packages(venv_container: VenvContainer, include_injected: bool) -> Exit
         print(package_summary)
         all_venv_problems.or_(venv_problems)
 
+    return all_venv_problems
+
+
+def list_json(venv_dirs: Collection[Path]) -> VenvProblems:
+    warning_messages = []
+    spec_metadata: Dict[str, Any] = {
+        "pipx_spec_version": PIPX_SPEC_VERSION,
+        "venvs": {},
+    }
+    all_venv_problems = VenvProblems()
+    for venv_dir in venv_dirs:
+        (venv_metadata, venv_problems, warning_str) = get_venv_metadata_summary(
+            venv_dir
+        )
+        all_venv_problems.or_(venv_problems)
+        if venv_problems.any_():
+            warning_messages.append(warning_str)
+            continue
+
+        spec_metadata["venvs"][venv_dir.name] = {}
+        spec_metadata["venvs"][venv_dir.name]["metadata"] = venv_metadata.to_dict()
+
+    print(
+        json.dumps(spec_metadata, indent=4, sort_keys=True, cls=JsonEncoderHandlesPath)
+    )
+    for warning_message in warning_messages:
+        print(warning_message, file=sys.stderr)
+
+    return all_venv_problems
+
+
+def list_packages(
+    venv_container: VenvContainer, include_injected: bool, json_format: bool
+) -> ExitCode:
+    """Returns pipx exit code."""
+    venv_dirs: Collection[Path] = sorted(venv_container.iter_venv_dirs())
+    if not venv_dirs:
+        print(f"nothing has been installed with pipx {sleep}")
+        return EXIT_CODE_OK
+
+    venv_container.verify_shared_libs()
+
+    if json_format:
+        all_venv_problems = list_json(venv_dirs)
+    else:
+        all_venv_problems = list_text(venv_dirs, include_injected, str(venv_container))
+
     if all_venv_problems.bad_venv_name:
         print(
             "\nOne or more packages contain out-of-date internal data installed from a\n"
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 477e3d3..5da55be 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -231,7 +231,7 @@ def run_pipx_command(args: argparse.Namespace) -> ExitCode:  # noqa: C901
             force=args.force,
         )
     elif args.command == "list":
-        return commands.list_packages(venv_container, args.include_injected)
+        return commands.list_packages(venv_container, args.include_injected, args.json)
     elif args.command == "uninstall":
         return commands.uninstall(venv_dir, constants.LOCAL_BIN_DIR, verbose)
     elif args.command == "uninstall-all":
@@ -488,6 +488,9 @@ def _add_list(subparsers: argparse._SubParsersAction) -> None:
         action="store_true",
         help="Show packages injected into the main app's environment",
     )
+    p.add_argument(
+        "--json", action="store_true", help="Output rich data in json format.",
+    )
     p.add_argument("--verbose", action="store_true")
 
 


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
index 198513b..2633def 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -10,6 +10,7 @@ from unittest import mock
 
 from packaging.utils import canonicalize_name
 
+from package_info import PKG
 from pipx import constants, main, pipx_metadata_file
 
 WIN = sys.platform.startswith("win")
@@ -138,3 +139,46 @@ def mock_legacy_venv(venv_name: str, metadata_version: Optional[str] = None) ->
             sort_keys=True,
             cls=pipx_metadata_file.JsonEncoderHandlesPath,
         )
+
+
+def create_package_info_ref(venv_name, package_name, pipx_venvs_dir, **field_overrides):
+    """Create reference PackageInfo to check against
+
+    Overridable fields to be used in field_overrides:
+        pip_args (default: [])
+        include_apps (default: True)
+        include_dependencies (default: False)
+        app_paths_of_dependencies (default: {})
+    """
+    venv_bin_dir = "Scripts" if constants.WINDOWS else "bin"
+    return pipx_metadata_file.PackageInfo(
+        package=package_name,
+        package_or_url=PKG[package_name]["spec"],
+        pip_args=field_overrides.get("pip_args", []),
+        include_apps=field_overrides.get("include_apps", True),
+        include_dependencies=field_overrides.get("include_dependencies", False),
+        apps=PKG[package_name]["apps"],
+        app_paths=[
+            pipx_venvs_dir / venv_name / venv_bin_dir / app
+            for app in PKG[package_name]["apps"]
+        ],
+        apps_of_dependencies=PKG[package_name]["apps_of_dependencies"],
+        app_paths_of_dependencies=field_overrides.get("app_paths_of_dependencies", {}),
+        package_version=PKG[package_name]["spec"].split("==")[-1],
+    )
+
+
+def assert_package_metadata(test_metadata, ref_metadata):
+    # only compare sorted versions of apps, app_paths so order is not important
+
+    assert test_metadata.package_version != ""
+    assert isinstance(test_metadata.apps, list)
+    assert isinstance(test_metadata.app_paths, list)
+
+    test_metadata_replaced = test_metadata._replace(
+        apps=sorted(test_metadata.apps), app_paths=sorted(test_metadata.app_paths)
+    )
+    ref_metadata_replaced = ref_metadata._replace(
+        apps=sorted(ref_metadata.apps), app_paths=sorted(ref_metadata.app_paths),
+    )
+    assert test_metadata_replaced == ref_metadata_replaced
diff --git a/tests/package_info.py b/tests/package_info.py
index 3cc68e5..89244c2 100644
--- a/tests/package_info.py
+++ b/tests/package_info.py
@@ -11,7 +11,7 @@ def _exe_if_win(apps):
 
 
 # Versions of all packages possibly used in our tests
-# Only apply _app_names to entry_points, NOT scripts
+# Only apply _exe_if_win to entry_points, NOT scripts
 PKG: Dict[str, Dict[str, Any]] = {
     "ansible": {
         "spec": "ansible==2.9.13",
diff --git a/tests/test_list.py b/tests/test_list.py
index 7b0fd86..b93f945 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -1,7 +1,18 @@
+import json
+import re
+
 import pytest  # type: ignore
 
-from helpers import mock_legacy_venv, run_pipx_cli
+from helpers import (
+    app_name,
+    assert_package_metadata,
+    create_package_info_ref,
+    mock_legacy_venv,
+    run_pipx_cli,
+)
+from package_info import PKG
 from pipx import constants, util
+from pipx.pipx_metadata_file import PackageInfo, _json_decoder_object_hook
 
 
 def test_cli(pipx_temp_env, monkeypatch, capsys):
@@ -59,3 +70,66 @@ def test_list_suffix_legacy_venv(pipx_temp_env, monkeypatch, capsys, metadata_ve
     assert not run_pipx_cli(["list"])
     captured = capsys.readouterr()
     assert f"package pycowsay 0.0.0.1 (pycowsay{suffix})," in captured.out
+
+
+def test_list_json(pipx_temp_env, capsys):
+    pipx_venvs_dir = constants.PIPX_HOME / "venvs"
+    venv_bin_dir = "Scripts" if constants.WINDOWS else "bin"
+
+    assert not run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
+    assert not run_pipx_cli(["install", PKG["pylint"]["spec"]])
+    assert not run_pipx_cli(["inject", "pylint", PKG["isort"]["spec"]])
+    captured = capsys.readouterr()
+
+    assert not run_pipx_cli(["list", "--json"])
+    captured = capsys.readouterr()
+
+    assert not re.search(r"\S", captured.err)
+    json_parsed = json.loads(captured.out, object_hook=_json_decoder_object_hook)
+
+    # raises error if not valid json
+    assert sorted(json_parsed["venvs"].keys()) == ["pycowsay", "pylint"]
+
+    # pycowsay venv
+    pycowsay_package_ref = create_package_info_ref(
+        "pycowsay", "pycowsay", pipx_venvs_dir
+    )
+    assert_package_metadata(
+        PackageInfo(**json_parsed["venvs"]["pycowsay"]["metadata"]["main_package"]),
+        pycowsay_package_ref,
+    )
+    assert json_parsed["venvs"]["pycowsay"]["metadata"]["injected_packages"] == {}
+
+    # pylint venv
+    pylint_package_ref = create_package_info_ref(
+        "pylint",
+        "pylint",
+        pipx_venvs_dir,
+        **{
+            "app_paths_of_dependencies": {
+                "isort": [pipx_venvs_dir / "pylint" / venv_bin_dir / app_name("isort")]
+            },
+        },
+    )
+    assert_package_metadata(
+        PackageInfo(**json_parsed["venvs"]["pylint"]["metadata"]["main_package"]),
+        pylint_package_ref,
+    )
+    assert sorted(
+        json_parsed["venvs"]["pylint"]["metadata"]["injected_packages"].keys()
+    ) == ["isort"]
+    isort_package_ref = create_package_info_ref(
+        "pylint", "isort", pipx_venvs_dir, include_apps=False
+    )
+    print(isort_package_ref)
+    print(
+        PackageInfo(
+            **json_parsed["venvs"]["pylint"]["metadata"]["injected_packages"]["isort"]
+        )
+    )
+    assert_package_metadata(
+        PackageInfo(
+            **json_parsed["venvs"]["pylint"]["metadata"]["injected_packages"]["isort"]
+        ),
+        isort_package_ref,
+    )
diff --git a/tests/test_pipx_metadata_file.py b/tests/test_pipx_metadata_file.py
index 3068402..9e321e3 100644
--- a/tests/test_pipx_metadata_file.py
+++ b/tests/test_pipx_metadata_file.py
@@ -3,7 +3,8 @@ from pathlib import Path
 import pytest  # type: ignore
 
 import pipx.constants
-from helpers import run_pipx_cli
+from helpers import assert_package_metadata, create_package_info_ref, run_pipx_cli
+from package_info import PKG
 from pipx.pipx_metadata_file import PackageInfo, PipxMetadata
 from pipx.util import PipxError
 
@@ -32,32 +33,6 @@ TEST_PACKAGE2 = PackageInfo(
     package_version="6.7.8",
 )
 
-# Reference metadata for various packages
-PYCOWSAY_PACKAGE_REF = PackageInfo(
-    package="pycowsay",
-    package_or_url="pycowsay",
-    pip_args=[],
-    include_dependencies=False,
-    include_apps=True,
-    apps=["pycowsay"],
-    app_paths=[Path("pycowsay/bin/pycowsay")],  # Placeholder, not real path
-    apps_of_dependencies=[],
-    app_paths_of_dependencies={},
-    package_version="0.0.0.1",
-)
-BLACK_PACKAGE_REF = PackageInfo(
-    package="black",
-    package_or_url="black==19.10b0",
-    pip_args=[],
-    include_dependencies=False,
-    include_apps=True,
-    apps=["pycowsay"],
-    app_paths=[Path("black/bin/black")],  # Placeholder, not real path
-    apps_of_dependencies=[],
-    app_paths_of_dependencies={},
-    package_version="19.10b0",
-)
-
 
 def test_pipx_metadata_file_create(tmp_path):
     venv_dir = tmp_path / TEST_PACKAGE1.package
@@ -104,76 +79,31 @@ def test_pipx_metadata_file_validation(tmp_path, test_package):
         pipx_metadata.write()
 
 
-def assert_package_metadata(test_metadata, ref_metadata):
-    # update package version of ref with recent package version
-    # only compare sets for apps, app_paths so order is not important
-
-    assert test_metadata.package_version != ""
-    assert isinstance(test_metadata.apps, list)
-    assert isinstance(test_metadata.app_paths, list)
-
-    test_metadata_replaced = test_metadata._replace(
-        apps=set(test_metadata.apps), app_paths=set(test_metadata.apps)
-    )
-    ref_metadata_replaced = ref_metadata._replace(
-        apps=set(ref_metadata.apps),
-        app_paths=set(ref_metadata.apps),
-        package_version=test_metadata.package_version,
-    )
-    assert test_metadata_replaced == ref_metadata_replaced
-
-
 def test_package_install(monkeypatch, tmp_path, pipx_temp_env):
     pipx_venvs_dir = pipx.constants.PIPX_HOME / "venvs"
 
-    run_pipx_cli(["install", "pycowsay"])
+    run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
     assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file()
 
     pipx_metadata = PipxMetadata(pipx_venvs_dir / "pycowsay")
-
-    if pipx.constants.WINDOWS:
-        ref_replacement_fields = {
-            "app_paths": [pipx_venvs_dir / "pycowsay" / "Scripts" / "pycowsay.exe"],
-            "apps": ["pycowsay.exe"],
-        }
-    else:
-        ref_replacement_fields = {
-            "app_paths": [pipx_venvs_dir / "pycowsay" / "bin" / "pycowsay"]
-        }
-    assert_package_metadata(
-        pipx_metadata.main_package,
-        PYCOWSAY_PACKAGE_REF._replace(include_apps=True, **ref_replacement_fields),
+    pycowsay_package_ref = create_package_info_ref(
+        "pycowsay", "pycowsay", pipx_venvs_dir
     )
+    assert_package_metadata(pipx_metadata.main_package, pycowsay_package_ref)
+    assert pipx_metadata.injected_packages == {}
 
 
 def test_package_inject(monkeypatch, tmp_path, pipx_temp_env):
     pipx_venvs_dir = pipx.constants.PIPX_HOME / "venvs"
 
-    run_pipx_cli(["install", "pycowsay"])
-    run_pipx_cli(["inject", "pycowsay", "black==19.10b0"])
-    assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file()
+    run_pipx_cli(["install", PKG["pycowsay"]["spec"]])
+    run_pipx_cli(["inject", "pycowsay", PKG["black"]["spec"]])
 
+    assert (pipx_venvs_dir / "pycowsay" / "pipx_metadata.json").is_file()
     pipx_metadata = PipxMetadata(pipx_venvs_dir / "pycowsay")
 
     assert pipx_metadata.injected_packages.keys() == {"black"}
-
-    if pipx.constants.WINDOWS:
-        ref_replacement_fields = {
-            "apps": ["black.exe", "blackd.exe"],
-            "app_paths": [
-                pipx_venvs_dir / "pycowsay" / "Scripts" / "black.exe",
-                pipx_venvs_dir / "pycowsay" / "Scripts" / "blackd.exe",
-            ],
-        }
-    else:
-        ref_replacement_fields = {
-            "apps": ["black", "blackd"],
-            "app_paths": [
-                pipx_venvs_dir / "pycowsay" / "bin" / "black",
-                pipx_venvs_dir / "pycowsay" / "bin" / "blackd",
-            ],
-        }
-    assert_package_metadata(
-        pipx_metadata.injected_packages["black"],
-        BLACK_PACKAGE_REF._replace(include_apps=False, **ref_replacement_fields),
+    black_package_ref = create_package_info_ref(
+        "pycowsay", "black", pipx_venvs_dir, include_apps=False
     )
+    assert_package_metadata(pipx_metadata.injected_packages["black"], black_package_ref)