Repository: tox-dev/pipdeptree
Pull request #471 (2025-04-20 00:10:41)

Handle exceptions when passed an invalid interpreter
Before we would just crash if given an invalid interpreter.

This change also does some code refactoring.


Patch:
diff --git a/src/pipdeptree/__main__.py b/src/pipdeptree/__main__.py
index ca9a973..0cea4cf 100644
--- a/src/pipdeptree/__main__.py
+++ b/src/pipdeptree/__main__.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
 
 from pipdeptree._cli import get_options
 from pipdeptree._detect_env import detect_active_interpreter
-from pipdeptree._discovery import get_installed_distributions
+from pipdeptree._discovery import InterpreterQueryError, get_installed_distributions
 from pipdeptree._models import PackageDAG
 from pipdeptree._render import render
 from pipdeptree._validate import validate
@@ -33,12 +33,17 @@ def main(args: Sequence[str] | None = None) -> int | None:
         options.python = resolved_path
         print(f"(resolved python: {resolved_path})", file=sys.stderr)  # noqa: T201
 
-    pkgs = get_installed_distributions(
-        interpreter=options.python,
-        supplied_paths=options.path or None,
-        local_only=options.local_only,
-        user_only=options.user_only,
-    )
+    try:
+        pkgs = get_installed_distributions(
+            interpreter=options.python,
+            supplied_paths=options.path or None,
+            local_only=options.local_only,
+            user_only=options.user_only,
+        )
+    except InterpreterQueryError as e:
+        print(f"Failed to query custom interpreter: {e}", file=sys.stderr)  # noqa: T201
+        return 1
+
     tree = PackageDAG.from_pkgs(pkgs)
 
     validate(tree)
diff --git a/src/pipdeptree/_discovery.py b/src/pipdeptree/_discovery.py
index 9c19d93..e6dab20 100644
--- a/src/pipdeptree/_discovery.py
+++ b/src/pipdeptree/_discovery.py
@@ -16,33 +16,30 @@ if TYPE_CHECKING:
     from collections.abc import Iterable
 
 
+class InterpreterQueryError(Exception):
+    """A problem occurred while trying to query a custom interpreter."""
+
+
 def get_installed_distributions(
-    interpreter: str = str(sys.executable),
+    interpreter: str = sys.executable or "",
     supplied_paths: list[str] | None = None,
     local_only: bool = False,  # noqa: FBT001, FBT002
     user_only: bool = False,  # noqa: FBT001, FBT002
 ) -> list[Distribution]:
-    # This will be the default since it's used by both importlib.metadata.PathDistribution and pip by default.
+    """
+    Return the distributions installed in the interpreter's environment.
+
+    :raises InterpreterQueryError: If a failure occurred while querying the interpreter.
+    """
+    # sys.path is used by importlib.metadata.PathDistribution and pip by default.
     computed_paths = supplied_paths or sys.path
 
     # See https://docs.python.org/3/library/venv.html#how-venvs-work for more details.
     in_venv = sys.prefix != sys.base_prefix
 
-    py_path = Path(interpreter).absolute()
-    using_custom_interpreter = py_path != Path(sys.executable).absolute()
-    should_query_interpreter = using_custom_interpreter and not supplied_paths
-
+    should_query_interpreter = not supplied_paths and (Path(interpreter).absolute() != Path(sys.executable).absolute())
     if should_query_interpreter:
-        # We query the interpreter directly to get its `sys.path`. If both --python and --local-only are given, only
-        # snatch metadata associated to the interpreter's environment.
-        if local_only:
-            cmd = "import sys; print([p for p in sys.path if p.startswith(sys.prefix)])"
-        else:
-            cmd = "import sys; print(sys.path)"
-
-        args = [str(py_path), "-c", cmd]
-        result = subprocess.run(args, stdout=subprocess.PIPE, check=False, text=True)  # noqa: S603
-        computed_paths = ast.literal_eval(result.stdout)
+        computed_paths = query_interpreter_for_paths(interpreter, local_only=local_only)
     elif local_only and in_venv:
         computed_paths = [p for p in computed_paths if p.startswith(sys.prefix)]
 
@@ -52,6 +49,27 @@ def get_installed_distributions(
     return filter_valid_distributions(distributions(path=computed_paths))
 
 
+def query_interpreter_for_paths(interpreter: str, *, local_only: bool = False) -> list[str]:
+    """
+    Query an interpreter for paths containing distribution metadata.
+
+    :raises InterpreterQueryError: If a failure occurred while querying the interpreter.
+    """
+    # We query the interpreter directly to get its `sys.path`. If both --python and --local-only are given, only
+    # snatch metadata associated to the interpreter's environment.
+    if local_only:
+        cmd = "import sys; print([p for p in sys.path if p.startswith(sys.prefix)])"
+    else:
+        cmd = "import sys; print(sys.path)"
+
+    args = [interpreter, "-c", cmd]
+    try:
+        result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=True, text=True)  # noqa: S603
+        return ast.literal_eval(result.stdout)  # type: ignore[no-any-return]
+    except Exception as e:
+        raise InterpreterQueryError(str(e)) from e
+
+
 def filter_valid_distributions(iterable_dists: Iterable[Distribution]) -> list[Distribution]:
     warning_printer = get_warning_printer()
 


Test patch:
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index dc9e117..38faa32 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -120,6 +120,16 @@ def test_user_only_when_in_virtual_env_and_system_site_pkgs_enabled(
     assert found == expected
 
 
+def test_interpreter_query_failure(mocker: MockerFixture, capfd: pytest.CaptureFixture[str]) -> None:
+    cmd = ["", "--python", "i-dont-exist"]
+    mocker.patch("pipdeptree._discovery.sys.argv", cmd)
+
+    main()
+
+    _, err = capfd.readouterr()
+    assert err.startswith("Failed to query custom interpreter")
+
+
 def test_duplicate_metadata(mocker: MockerFixture, capfd: pytest.CaptureFixture[str]) -> None:
     mocker.patch(
         "pipdeptree._discovery.distributions",