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