diff --git a/docs/html/topics/index.md b/docs/html/topics/index.md index 011205a111d..c5e4d36c95f 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 00000000000..5ad46e7af9c --- /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 00000000000..843eac7c9f4 --- /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/src/pip/_internal/build_env.py b/src/pip/_internal/build_env.py index 6d4f6a56eb7..6213eedd14a 100644 --- a/src/pip/_internal/build_env.py +++ b/src/pip/_internal/build_env.py @@ -39,7 +39,7 @@ def __init__(self, path: str) -> None: 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 @@ def install_requirements( 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 47ed92779e9..84e0e783869 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -189,6 +189,13 @@ class PipOption(Option): ), ) +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 @@ def check_list_path_option(options: Values) -> None: 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 3666ab04ca6..5ade356b9c2 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/tests/functional/test_python_option.py b/tests/functional/test_python_option.py new file mode 100644 index 00000000000..8bf16d7a56b --- /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 1e5ef995cd0..8c33ca8c18d 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