Repository: pypa/pip
Pull request #11320 (2022-07-28 11:16:43)

Add a --python option
This is an initial draft for a `--python` option for pip, that runs pip with a specified Python interpreter.

To do:

- [x] Tests
- [x] Documentation
- [x] Refactoring (`_get_runnable_pip` should probably be moved somewhere more general)
- [x] Support friendlier interpreter names, not just the full path (check how other apps do this - is there a common library?)

The use of an environment variable to stop infinitely creating new subprocesses feels OK to me, but is there a better way? Stripping the `--python` option out of the command line is probably impractical (i.e., even more risk of missing something and fork-bombing the user's machine 🙁).


Patch:
diff --git a/docs/html/topics/index.md b/docs/html/topics/index.md
index 011205a11..c5e4d36c9 100644
--- a/docs/html/topics/index.md
+++ b/docs/html/topics/index.md
@@ -18,4 +18,5 @@ local-project-installs
 repeatable-installs
 secure-installs
 vcs-support
+python-option
 ```
diff --git a/docs/html/topics/python-option.md b/docs/html/topics/python-option.md
new file mode 100644
index 000000000..5ad46e7af
--- /dev/null
+++ b/docs/html/topics/python-option.md
@@ -0,0 +1,29 @@
+# Managing a different Python interpreter
+
+```{versionadded} 22.3
+```
+
+Occasionally, you may want to use pip to manage a Python installation other than
+the one pip is installed into. In this case, you can use the `--python` option
+to specify the interpreter you want to manage. This option can take one of two
+values:
+
+1. The path to a Python executable.
+2. The path to a virtual environment.
+
+In both cases, pip will run exactly as if it had been invoked from that Python
+environment.
+
+One example of where this might be useful is to manage a virtual environment
+that does not have pip installed.
+
+```{pip-cli}
+$ python -m venv .venv --without-pip
+$ pip --python .venv install SomePackage
+[...]
+Successfully installed SomePackage
+```
+
+You could also use `--python .venv/bin/python` (or on Windows,
+`--python .venv\Scripts\python.exe`) if you wanted to be explicit, but the
+virtual environment name is shorter and works exactly the same.
diff --git a/news/11320.feature.rst b/news/11320.feature.rst
new file mode 100644
index 000000000..843eac7c9
--- /dev/null
+++ b/news/11320.feature.rst
@@ -0,0 +1,2 @@
+Add a ``--python`` option to allow pip to manage Python environments other
+than the one pip is installed in.
diff --git a/news/11330.bugfix.rst b/news/11330.bugfix.rst
deleted file mode 100644
index e03501fe5..000000000
--- a/news/11330.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Show pip deprecation warnings by default.
diff --git a/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py
index 6d4f6a56e..6213eedd1 100644
--- a/src/pip/_internal/build_env.py
+++ b/src/pip/_internal/build_env.py
@@ -39,7 +39,7 @@ class _Prefix:
         self.lib_dirs = get_prefixed_libs(path)
 
 
-def _get_runnable_pip() -> str:
+def get_runnable_pip() -> str:
     """Get a file to pass to a Python executable, to run the currently-running pip.
 
     This is used to run a pip subprocess, for installing requirements into the build
@@ -194,7 +194,7 @@ class BuildEnvironment:
         if not requirements:
             return
         self._install_requirements(
-            _get_runnable_pip(),
+            get_runnable_pip(),
             finder,
             requirements,
             prefix,
diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py
index 47ed92779..84e0e7838 100644
--- a/src/pip/_internal/cli/cmdoptions.py
+++ b/src/pip/_internal/cli/cmdoptions.py
@@ -189,6 +189,13 @@ require_virtualenv: Callable[..., Option] = partial(
     ),
 )
 
+python: Callable[..., Option] = partial(
+    Option,
+    "--python",
+    dest="python",
+    help="Run pip with the specified Python interpreter.",
+)
+
 verbose: Callable[..., Option] = partial(
     Option,
     "-v",
@@ -1029,6 +1036,7 @@ general_group: Dict[str, Any] = {
         debug_mode,
         isolated_mode,
         require_virtualenv,
+        python,
         verbose,
         version,
         quiet,
diff --git a/src/pip/_internal/cli/main_parser.py b/src/pip/_internal/cli/main_parser.py
index 3666ab04c..5ade356b9 100644
--- a/src/pip/_internal/cli/main_parser.py
+++ b/src/pip/_internal/cli/main_parser.py
@@ -2,9 +2,11 @@
 """
 
 import os
+import subprocess
 import sys
-from typing import List, Tuple
+from typing import List, Optional, Tuple
 
+from pip._internal.build_env import get_runnable_pip
 from pip._internal.cli import cmdoptions
 from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
 from pip._internal.commands import commands_dict, get_similar_commands
@@ -45,6 +47,25 @@ def create_main_parser() -> ConfigOptionParser:
     return parser
 
 
+def identify_python_interpreter(python: str) -> Optional[str]:
+    # If the named file exists, use it.
+    # If it's a directory, assume it's a virtual environment and
+    # look for the environment's Python executable.
+    if os.path.exists(python):
+        if os.path.isdir(python):
+            # bin/python for Unix, Scripts/python.exe for Windows
+            # Try both in case of odd cases like cygwin.
+            for exe in ("bin/python", "Scripts/python.exe"):
+                py = os.path.join(python, exe)
+                if os.path.exists(py):
+                    return py
+        else:
+            return python
+
+    # Could not find the interpreter specified
+    return None
+
+
 def parse_command(args: List[str]) -> Tuple[str, List[str]]:
     parser = create_main_parser()
 
@@ -57,6 +78,32 @@ def parse_command(args: List[str]) -> Tuple[str, List[str]]:
     #  args_else: ['install', '--user', 'INITools']
     general_options, args_else = parser.parse_args(args)
 
+    # --python
+    if general_options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ:
+        # Re-invoke pip using the specified Python interpreter
+        interpreter = identify_python_interpreter(general_options.python)
+        if interpreter is None:
+            raise CommandError(
+                f"Could not locate Python interpreter {general_options.python}"
+            )
+
+        pip_cmd = [
+            interpreter,
+            get_runnable_pip(),
+        ]
+        pip_cmd.extend(args)
+
+        # Set a flag so the child doesn't re-invoke itself, causing
+        # an infinite loop.
+        os.environ["_PIP_RUNNING_IN_SUBPROCESS"] = "1"
+        returncode = 0
+        try:
+            proc = subprocess.run(pip_cmd)
+            returncode = proc.returncode
+        except (subprocess.SubprocessError, OSError) as exc:
+            raise CommandError(f"Failed to run pip under {interpreter}: {exc}")
+        sys.exit(returncode)
+
     # --version
     if general_options.version:
         sys.stdout.write(parser.version)
diff --git a/src/pip/_internal/index/collector.py b/src/pip/_internal/index/collector.py
index c8e120b51..6e5dac5ad 100644
--- a/src/pip/_internal/index/collector.py
+++ b/src/pip/_internal/index/collector.py
@@ -345,7 +345,6 @@ def parse_links(page: "IndexContent") -> Iterable[Link]:
                 yanked_reason=yanked_reason,
                 hashes=file.get("hashes", {}),
             )
-        return
 
     parser = HTMLLinkParser(page.url)
     encoding = page.encoding or "utf-8"
@@ -448,7 +447,7 @@ def _get_index_content(
 ) -> Optional["IndexContent"]:
     if session is None:
         raise TypeError(
-            "_get_index_content() missing 1 required keyword argument: 'session'"
+            "_get_html_page() missing 1 required keyword argument: 'session'"
         )
 
     url = link.url.split("#", 1)[0]
diff --git a/src/pip/_internal/network/lazy_wheel.py b/src/pip/_internal/network/lazy_wheel.py
index 854a6fa1f..2d1ddaa89 100644
--- a/src/pip/_internal/network/lazy_wheel.py
+++ b/src/pip/_internal/network/lazy_wheel.py
@@ -23,7 +23,7 @@ class HTTPRangeRequestUnsupported(Exception):
 def dist_from_wheel_url(name: str, url: str, session: PipSession) -> BaseDistribution:
     """Return a distribution object from the given wheel URL.
 
-    This uses HTTP range requests to only fetch the portion of the wheel
+    This uses HTTP range requests to only fetch the potion of the wheel
     containing metadata, just enough for the object to be constructed.
     If such requests are not supported, HTTPRangeRequestUnsupported
     is raised.
diff --git a/src/pip/_internal/utils/deprecation.py b/src/pip/_internal/utils/deprecation.py
index 72bd6f25a..7964095ff 100644
--- a/src/pip/_internal/utils/deprecation.py
+++ b/src/pip/_internal/utils/deprecation.py
@@ -13,7 +13,7 @@ from pip import __version__ as current_version  # NOTE: tests patch this name.
 DEPRECATION_MSG_PREFIX = "DEPRECATION: "
 
 
-class PipDeprecationWarning(Warning):
+class PipDeprecationWarning(DeprecationWarning):
     pass
 
 


Test patch:
diff --git a/tests/functional/test_python_option.py b/tests/functional/test_python_option.py
new file mode 100644
index 000000000..8bf16d7a5
--- /dev/null
+++ b/tests/functional/test_python_option.py
@@ -0,0 +1,41 @@
+import json
+import os
+from pathlib import Path
+from venv import EnvBuilder
+
+from tests.lib import PipTestEnvironment, TestData
+
+
+def test_python_interpreter(
+    script: PipTestEnvironment,
+    tmpdir: Path,
+    shared_data: TestData,
+) -> None:
+    env_path = os.fspath(tmpdir / "venv")
+    env = EnvBuilder(with_pip=False)
+    env.create(env_path)
+
+    result = script.pip("--python", env_path, "list", "--format=json")
+    before = json.loads(result.stdout)
+
+    # Ideally we would assert that before==[], but there's a problem in CI
+    # that means this isn't true. See https://github.com/pypa/pip/pull/11326
+    # for details.
+
+    script.pip(
+        "--python",
+        env_path,
+        "install",
+        "-f",
+        shared_data.find_links,
+        "--no-index",
+        "simplewheel==1.0",
+    )
+
+    result = script.pip("--python", env_path, "list", "--format=json")
+    installed = json.loads(result.stdout)
+    assert {"name": "simplewheel", "version": "1.0"} in installed
+
+    script.pip("--python", env_path, "uninstall", "simplewheel", "--yes")
+    result = script.pip("--python", env_path, "list", "--format=json")
+    assert json.loads(result.stdout) == before
diff --git a/tests/unit/test_cmdoptions.py b/tests/unit/test_cmdoptions.py
index 1e5ef995c..8c33ca8c1 100644
--- a/tests/unit/test_cmdoptions.py
+++ b/tests/unit/test_cmdoptions.py
@@ -1,8 +1,12 @@
+import os
+from pathlib import Path
 from typing import Optional, Tuple
+from venv import EnvBuilder
 
 import pytest
 
 from pip._internal.cli.cmdoptions import _convert_python_version
+from pip._internal.cli.main_parser import identify_python_interpreter
 
 
 @pytest.mark.parametrize(
@@ -29,3 +33,20 @@ def test_convert_python_version(
 ) -> None:
     actual = _convert_python_version(value)
     assert actual == expected, f"actual: {actual!r}"
+
+
+def test_identify_python_interpreter_venv(tmpdir: Path) -> None:
+    env_path = tmpdir / "venv"
+    env = EnvBuilder(with_pip=False)
+    env.create(env_path)
+
+    # Passing a virtual environment returns the Python executable
+    interp = identify_python_interpreter(os.fspath(env_path))
+    assert interp is not None
+    assert Path(interp).exists()
+
+    # Passing an executable returns it
+    assert identify_python_interpreter(interp) == interp
+
+    # Passing a non-existent file returns None
+    assert identify_python_interpreter(str(tmpdir / "nonexistent")) is None