Repository: pypa/pipx (python)

Issue:
Command for cleanup of installed standalone python versions
**How would this feature be useful?**

<!-- Describe any use cases this solves or frustrations it alleviates.   -->
#1242 introduced the ability to have pipx download standalone python versions, if the requested python version is not available. Currently, there is no way through pipx to delete them again. Having a way for pipx to figure out which interpreters are still in use and delete the ones that aren't would be very useful for housekeeping.

Also see the discussions in the merge review of the feature for more information: https://github.com/pypa/pipx/pull/1243#issuecomment-1932170921

**Describe the solution you'd like**

<!-- If you have an idea of how to do this, write it here! -->
A new command that checks the interpreters of the installed applications, compares it with the available standalone python versions and deletes the versions that are no longer in use.

**Describe alternatives you've considered**

<!-- If there's some workaround or alternative solutions, let us know. -->
Manually going to the `PIPX_STANDALONE_PYTHON_CACHEDIR` folder and removing python versions, hoping they are not used anymore.


Pull request:
Add interpreter command
<!-- 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

Add a `interpreter` command with `list` and `prune` subcommand. They can be used to list the installed standalone interpreters and prune unused standalone interpreters.
Resolves #1248 

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Tested by running

```
pipx install --fetch-missing-python --python 3.7 pycowsay
pipx interpreter list
pipx uninstall pycowsay
pipx interpreter prune
```


Patch:
diff --git a/changelog.d/1248.feature.md b/changelog.d/1248.feature.md
new file mode 100644
index 0000000..0c16ea6
--- /dev/null
+++ b/changelog.d/1248.feature.md
@@ -0,0 +1,1 @@
+Add commands to list and prune standalone interpreters
diff --git a/scripts/generate_man.py b/scripts/generate_man.py
index d0831f7..e322348 100644
--- a/scripts/generate_man.py
+++ b/scripts/generate_man.py
@@ -12,7 +12,7 @@ from pipx.main import get_command_parser
 
 def main():
     sys.argv[0] = "pipx"
-    parser = get_command_parser()
+    parser, _ = get_command_parser()
     parser.man_short_description = cast(str, parser.description).splitlines()[1]  # type: ignore[attr-defined]
 
     manpage = Manpage(parser)
diff --git a/src/pipx/commands/__init__.py b/src/pipx/commands/__init__.py
index 5c03bb2..b0f4677 100644
--- a/src/pipx/commands/__init__.py
+++ b/src/pipx/commands/__init__.py
@@ -2,6 +2,7 @@ from pipx.commands.ensure_path import ensure_pipx_paths
 from pipx.commands.environment import environment
 from pipx.commands.inject import inject
 from pipx.commands.install import install
+from pipx.commands.interpreter import list_interpreters, prune_interpreters
 from pipx.commands.list_packages import list_packages
 from pipx.commands.reinstall import reinstall, reinstall_all
 from pipx.commands.run import run
@@ -25,4 +26,6 @@ __all__ = [
     "run_pip",
     "ensure_pipx_paths",
     "environment",
+    "list_interpreters",
+    "prune_interpreters",
 ]
diff --git a/src/pipx/commands/interpreter.py b/src/pipx/commands/interpreter.py
new file mode 100644
index 0000000..6181dbe
--- /dev/null
+++ b/src/pipx/commands/interpreter.py
@@ -0,0 +1,70 @@
+from pathlib import Path
+from typing import List
+
+from pipx import constants
+from pipx.pipx_metadata_file import PipxMetadata
+from pipx.util import is_paths_relative, rmdir
+from pipx.venv import Venv, VenvContainer
+
+
+def get_installed_standalone_interpreters() -> List[Path]:
+    return [python_dir for python_dir in constants.PIPX_STANDALONE_PYTHON_CACHEDIR.iterdir() if python_dir.is_dir()]
+
+
+def get_venvs_using_standalone_interpreter(venv_container: VenvContainer) -> List[Venv]:
+    venvs: list[Venv] = []
+    for venv_dir in venv_container.iter_venv_dirs():
+        venv = Venv(venv_dir)
+        if venv.pipx_metadata.source_interpreter:
+            venvs.append(venv)
+    return venvs
+
+
+def get_interpreter_users(interpreter: Path, venvs: List[Venv]) -> List[PipxMetadata]:
+    return [
+        venv.pipx_metadata
+        for venv in venvs
+        if venv.pipx_metadata.source_interpreter
+        and is_paths_relative(venv.pipx_metadata.source_interpreter, interpreter)
+    ]
+
+
+def list_interpreters(
+    venv_container: VenvContainer,
+):
+    interpreters = get_installed_standalone_interpreters()
+    venvs = get_venvs_using_standalone_interpreter(venv_container)
+    output: list[str] = []
+    output.append(f"Standalone interpreters are in {constants.PIPX_STANDALONE_PYTHON_CACHEDIR}")
+    for interpreter in interpreters:
+        output.append(f"Python {interpreter.name}")
+        used_in = get_interpreter_users(interpreter, venvs)
+        if used_in:
+            output.append("    Used in:")
+            for p in used_in:
+                output.append(f"     - {p.main_package.package} {p.main_package.package_version}")
+        else:
+            output.append("    Unused")
+
+    print("\n".join(output))
+    return constants.EXIT_CODE_OK
+
+
+def prune_interpreters(
+    venv_container: VenvContainer,
+):
+    interpreters = get_installed_standalone_interpreters()
+    venvs = get_venvs_using_standalone_interpreter(venv_container)
+    removed = []
+    for interpreter in interpreters:
+        if get_interpreter_users(interpreter, venvs):
+            continue
+        rmdir(interpreter, safe_rm=True)
+        removed.append(interpreter.name)
+    if removed:
+        print("Successfully removed:")
+        for interpreter_name in removed:
+            print(f" - Python {interpreter_name}")
+    else:
+        print("Nothing to remove")
+    return constants.EXIT_CODE_OK
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 17148b5..b8a13f9 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -13,7 +13,7 @@ import textwrap
 import time
 import urllib.parse
 from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional, Tuple
 
 import argcomplete
 import platformdirs
@@ -24,6 +24,7 @@ from pipx import commands, constants
 from pipx.animate import hide_cursor, show_cursor
 from pipx.colors import bold, green
 from pipx.constants import (
+    EXIT_CODE_OK,
     EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND,
     MINIMUM_PYTHON_VERSION,
     WINDOWS,
@@ -174,7 +175,7 @@ def get_venv_args(parsed_args: Dict[str, str]) -> List[str]:
     return venv_args
 
 
-def run_pipx_command(args: argparse.Namespace) -> ExitCode:  # noqa: C901
+def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.ArgumentParser]) -> ExitCode:  # noqa: C901
     verbose = args.verbose if "verbose" in args else False
     pip_args = get_pip_args(vars(args))
     venv_args = get_venv_args(vars(args))
@@ -288,6 +289,16 @@ def run_pipx_command(args: argparse.Namespace) -> ExitCode:  # noqa: C901
             args.short,
             args.skip_maintenance,
         )
+    elif args.command == "interpreter":
+        if args.interpreter_command == "list":
+            return commands.list_interpreters(venv_container)
+        elif args.interpreter_command == "prune":
+            return commands.prune_interpreters(venv_container)
+        elif args.interpreter_command is None:
+            subparsers["interpreter"].print_help()
+            return EXIT_CODE_OK
+        else:
+            raise PipxError(f"Unknown interpreter command {args.interpreter_command}")
     elif args.command == "uninstall":
         return commands.uninstall(venv_dir, constants.LOCAL_BIN_DIR, constants.LOCAL_MAN_DIR, verbose)
     elif args.command == "uninstall-all":
@@ -588,6 +599,25 @@ def _add_list(subparsers: argparse._SubParsersAction, shared_parser: argparse.Ar
     g.add_argument("--skip-maintenance", action="store_true", help="Skip maintenance tasks.")
 
 
+def _add_interpreter(
+    subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser
+) -> argparse.ArgumentParser:
+    p: argparse.ArgumentParser = subparsers.add_parser(
+        "interpreter",
+        help="Interact with interpreters managed by pipx",
+        description="Interact with interpreters managed by pipx",
+        parents=[shared_parser],
+    )
+    s = p.add_subparsers(
+        title="subcommands",
+        description="Get help for commands with pipx interpreter COMMAND --help",
+        dest="interpreter_command",
+    )
+    s.add_parser("list", help="List available interpreters", description="List available interpreters")
+    s.add_parser("prune", help="Prune unused interpreters", description="Prune unused interpreters")
+    return p
+
+
 def _add_run(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
     p = subparsers.add_parser(
         "run",
@@ -706,7 +736,7 @@ def _add_environment(subparsers: argparse._SubParsersAction, shared_parser: argp
     p.add_argument("--value", "-V", metavar="VARIABLE", help="Print the value of the variable.")
 
 
-def get_command_parser() -> argparse.ArgumentParser:
+def get_command_parser() -> Tuple[argparse.ArgumentParser, Dict[str, argparse.ArgumentParser]]:
     venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS)
 
     completer_venvs = InstalledVenvsCompleter(venv_container)
@@ -736,6 +766,7 @@ def get_command_parser() -> argparse.ArgumentParser:
 
     subparsers = parser.add_subparsers(dest="command", description="Get help for commands with pipx COMMAND --help")
 
+    subparsers_with_subcommands = {}
     _add_install(subparsers, shared_parser)
     _add_uninject(subparsers, completer_venvs.use, shared_parser)
     _add_inject(subparsers, completer_venvs.use, shared_parser)
@@ -746,6 +777,7 @@ def get_command_parser() -> argparse.ArgumentParser:
     _add_reinstall(subparsers, completer_venvs.use, shared_parser)
     _add_reinstall_all(subparsers, shared_parser)
     _add_list(subparsers, shared_parser)
+    subparsers_with_subcommands["interpreter"] = _add_interpreter(subparsers, shared_parser)
     _add_run(subparsers, shared_parser)
     _add_runpip(subparsers, completer_venvs.use, shared_parser)
     _add_ensurepath(subparsers, shared_parser)
@@ -758,7 +790,7 @@ def get_command_parser() -> argparse.ArgumentParser:
         description="Print instructions on enabling shell completions for pipx",
         parents=[shared_parser],
     )
-    return parser
+    return parser, subparsers_with_subcommands
 
 
 def delete_oldest_logs(file_list: List[Path], keep_number: int) -> None:
@@ -917,7 +949,7 @@ def cli() -> ExitCode:
     """Entry point from command line"""
     try:
         hide_cursor()
-        parser = get_command_parser()
+        parser, subparsers = get_command_parser()
         argcomplete.autocomplete(parser)
         parsed_pipx_args = parser.parse_args()
         setup(parsed_pipx_args)
@@ -925,7 +957,7 @@ def cli() -> ExitCode:
         if not parsed_pipx_args.command:
             parser.print_help()
             return ExitCode(1)
-        return run_pipx_command(parsed_pipx_args)
+        return run_pipx_command(parsed_pipx_args, subparsers)
     except PipxError as e:
         print(str(e), file=sys.stderr)
         logger.debug(f"PipxError: {e}", exc_info=True)
diff --git a/src/pipx/standalone_python.py b/src/pipx/standalone_python.py
index 6ef20c4..346203f 100644
--- a/src/pipx/standalone_python.py
+++ b/src/pipx/standalone_python.py
@@ -13,8 +13,8 @@ from pathlib import Path
 from typing import Any, Dict, List
 from urllib.request import urlopen
 
+from pipx import constants
 from pipx.animate import animate
-from pipx.constants import PIPX_STANDALONE_PYTHON_CACHEDIR, WINDOWS
 from pipx.util import PipxError
 
 logger = logging.getLogger(__name__)
@@ -58,8 +58,8 @@ def download_python_build_standalone(python_version: str):
     # we'll convert it to a bare version number
     python_version = re.sub(r"[c]?python", "", python_version)
 
-    install_dir = PIPX_STANDALONE_PYTHON_CACHEDIR / python_version
-    installed_python = install_dir / "python.exe" if WINDOWS else install_dir / "bin" / "python3"
+    install_dir = constants.PIPX_STANDALONE_PYTHON_CACHEDIR / python_version
+    installed_python = install_dir / "python.exe" if constants.WINDOWS else install_dir / "bin" / "python3"
 
     if installed_python.exists():
         return str(installed_python)
@@ -125,7 +125,7 @@ def _unpack(full_version, download_link, archive: Path, download_dir: Path):
 def get_or_update_index():
     """Get or update the index of available python builds from
     the python-build-standalone repository."""
-    index_file = PIPX_STANDALONE_PYTHON_CACHEDIR / "index.json"
+    index_file = constants.PIPX_STANDALONE_PYTHON_CACHEDIR / "index.json"
     if index_file.exists():
         index = json.loads(index_file.read_text())
         # update index after 30 days
diff --git a/src/pipx/util.py b/src/pipx/util.py
index f219841..8ae3693 100644
--- a/src/pipx/util.py
+++ b/src/pipx/util.py
@@ -421,3 +421,12 @@ def pipx_wrap(text: str, subsequent_indent: str = "", keep_newlines: bool = Fals
             subsequent_indent=subsequent_indent,
             break_on_hyphens=False,
         )
+
+
+def is_paths_relative(path: Path, parent: Path):
+    # Can be replaced with path.is_relative_to() if support for python3.8 is dropped
+    try:
+        path.resolve().relative_to(parent.resolve())
+        return True
+    except ValueError:
+        return False


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 92bef08..e05f724 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -76,6 +76,7 @@ def pipx_temp_env_helper(pipx_shared_dir, tmp_path, monkeypatch, request, utils_
     monkeypatch.setattr(constants, "PIPX_HOME", home_dir)
     monkeypatch.setattr(constants, "LOCAL_BIN_DIR", bin_dir)
     monkeypatch.setattr(constants, "LOCAL_MAN_DIR", man_dir)
+    monkeypatch.setattr(constants, "PIPX_STANDALONE_PYTHON_CACHEDIR", home_dir / "py")
     monkeypatch.setattr(constants, "PIPX_LOCAL_VENVS", home_dir / "venvs")
     monkeypatch.setattr(constants, "PIPX_VENV_CACHEDIR", home_dir / ".cache")
     monkeypatch.setattr(constants, "PIPX_LOG_DIR", home_dir / "logs")
diff --git a/tests/test_run.py b/tests/test_run.py
index 7d93556..df7abde 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -115,7 +115,7 @@ def test_run_script_from_internet(pipx_temp_env, capsys):
     ],
 )
 def test_appargs_doubledash(pipx_temp_env, capsys, monkeypatch, input_run_args, expected_app_with_args):
-    parser = pipx.main.get_command_parser()
+    parser, _ = pipx.main.get_command_parser()
     monkeypatch.setattr(sys, "argv", ["pipx", "run"] + input_run_args)
     parsed_pipx_args = parser.parse_args()
     pipx.main.check_args(parsed_pipx_args)
diff --git a/tests/test_standalone_interpreter.py b/tests/test_standalone_interpreter.py
new file mode 100644
index 0000000..ab201c6
--- /dev/null
+++ b/tests/test_standalone_interpreter.py
@@ -0,0 +1,108 @@
+import shutil
+import sys
+
+from helpers import (
+    run_pipx_cli,
+)
+from package_info import PKG
+
+MAJOR_PYTHON_VERSION = sys.version_info.major
+# Minor version 3.8 is not supported for fetching standalone versions
+MINOR_PYTHON_VERSION = sys.version_info.minor if sys.version_info.minor != 8 else 9
+TARGET_PYTHON_VERSION = f"{MAJOR_PYTHON_VERSION}.{MINOR_PYTHON_VERSION}"
+
+original_which = shutil.which
+
+
+def mock_which(name):
+    if name == TARGET_PYTHON_VERSION:
+        return None
+    return original_which(name)
+
+
+def test_list_no_standalone_interpreters(pipx_temp_env, monkeypatch, capsys):
+    assert not run_pipx_cli(["interpreter", "list"])
+
+    captured = capsys.readouterr()
+    assert "Standalone interpreters" in captured.out
+    assert len(captured.out.splitlines()) == 1
+
+
+def test_list_used_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_github_api, capsys):
+    monkeypatch.setattr(shutil, "which", mock_which)
+
+    assert not run_pipx_cli(
+        [
+            "install",
+            "--fetch-missing-python",
+            "--python",
+            TARGET_PYTHON_VERSION,
+            PKG["pycowsay"]["spec"],
+        ]
+    )
+
+    capsys.readouterr()
+    assert not run_pipx_cli(["interpreter", "list"])
+
+    captured = capsys.readouterr()
+    assert TARGET_PYTHON_VERSION in captured.out
+    assert "pycowsay" in captured.out
+
+
+def test_list_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_github_api, capsys):
+    monkeypatch.setattr(shutil, "which", mock_which)
+
+    assert not run_pipx_cli(
+        [
+            "install",
+            "--fetch-missing-python",
+            "--python",
+            TARGET_PYTHON_VERSION,
+            PKG["pycowsay"]["spec"],
+        ]
+    )
+
+    assert not run_pipx_cli(["uninstall", "pycowsay"])
+    capsys.readouterr()
+    assert not run_pipx_cli(["interpreter", "list"])
+
+    captured = capsys.readouterr()
+    assert TARGET_PYTHON_VERSION in captured.out
+    assert "pycowsay" not in captured.out
+    assert "Unused" in captured.out
+
+
+def test_prune_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_github_api, capsys):
+    monkeypatch.setattr(shutil, "which", mock_which)
+
+    assert not run_pipx_cli(
+        [
+            "install",
+            "--fetch-missing-python",
+            "--python",
+            TARGET_PYTHON_VERSION,
+            PKG["pycowsay"]["spec"],
+        ]
+    )
+
+    capsys.readouterr()
+    assert not run_pipx_cli(["interpreter", "prune"])
+    captured = capsys.readouterr()
+    assert "Nothing to remove" in captured.out
+
+    assert not run_pipx_cli(["uninstall", "pycowsay"])
+    capsys.readouterr()
+
+    assert not run_pipx_cli(["interpreter", "prune"])
+    captured = capsys.readouterr()
+    assert "Successfully removed:" in captured.out
+    assert f"- Python {TARGET_PYTHON_VERSION}" in captured.out
+
+    assert not run_pipx_cli(["interpreter", "list"])
+    captured = capsys.readouterr()
+    assert "Standalone interpreters" in captured.out
+    assert len(captured.out.splitlines()) == 1
+
+    assert not run_pipx_cli(["interpreter", "prune"])
+    captured = capsys.readouterr()
+    assert "Nothing to remove" in captured.out