Skip to content

Commit 9473e83

Browse files
authored
Merge pull request #11320 from pfmoore/python_option
Add a --python option
2 parents 8070892 + 9b638ec commit 9473e83

File tree

8 files changed

+152
-3
lines changed

8 files changed

+152
-3
lines changed

docs/html/topics/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ local-project-installs
1919
repeatable-installs
2020
secure-installs
2121
vcs-support
22+
python-option
2223
```

docs/html/topics/python-option.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Managing a different Python interpreter
2+
3+
```{versionadded} 22.3
4+
```
5+
6+
Occasionally, you may want to use pip to manage a Python installation other than
7+
the one pip is installed into. In this case, you can use the `--python` option
8+
to specify the interpreter you want to manage. This option can take one of two
9+
values:
10+
11+
1. The path to a Python executable.
12+
2. The path to a virtual environment.
13+
14+
In both cases, pip will run exactly as if it had been invoked from that Python
15+
environment.
16+
17+
One example of where this might be useful is to manage a virtual environment
18+
that does not have pip installed.
19+
20+
```{pip-cli}
21+
$ python -m venv .venv --without-pip
22+
$ pip --python .venv install SomePackage
23+
[...]
24+
Successfully installed SomePackage
25+
```
26+
27+
You could also use `--python .venv/bin/python` (or on Windows,
28+
`--python .venv\Scripts\python.exe`) if you wanted to be explicit, but the
29+
virtual environment name is shorter and works exactly the same.

news/11320.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add a ``--python`` option to allow pip to manage Python environments other
2+
than the one pip is installed in.

src/pip/_internal/build_env.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(self, path: str) -> None:
3939
self.lib_dirs = get_prefixed_libs(path)
4040

4141

42-
def _get_runnable_pip() -> str:
42+
def get_runnable_pip() -> str:
4343
"""Get a file to pass to a Python executable, to run the currently-running pip.
4444
4545
This is used to run a pip subprocess, for installing requirements into the build
@@ -194,7 +194,7 @@ def install_requirements(
194194
if not requirements:
195195
return
196196
self._install_requirements(
197-
_get_runnable_pip(),
197+
get_runnable_pip(),
198198
finder,
199199
requirements,
200200
prefix,

src/pip/_internal/cli/cmdoptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ class PipOption(Option):
189189
),
190190
)
191191

192+
python: Callable[..., Option] = partial(
193+
Option,
194+
"--python",
195+
dest="python",
196+
help="Run pip with the specified Python interpreter.",
197+
)
198+
192199
verbose: Callable[..., Option] = partial(
193200
Option,
194201
"-v",
@@ -1029,6 +1036,7 @@ def check_list_path_option(options: Values) -> None:
10291036
debug_mode,
10301037
isolated_mode,
10311038
require_virtualenv,
1039+
python,
10321040
verbose,
10331041
version,
10341042
quiet,

src/pip/_internal/cli/main_parser.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
"""
33

44
import os
5+
import subprocess
56
import sys
6-
from typing import List, Tuple
7+
from typing import List, Optional, Tuple
78

9+
from pip._internal.build_env import get_runnable_pip
810
from pip._internal.cli import cmdoptions
911
from pip._internal.cli.parser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
1012
from pip._internal.commands import commands_dict, get_similar_commands
@@ -45,6 +47,25 @@ def create_main_parser() -> ConfigOptionParser:
4547
return parser
4648

4749

50+
def identify_python_interpreter(python: str) -> Optional[str]:
51+
# If the named file exists, use it.
52+
# If it's a directory, assume it's a virtual environment and
53+
# look for the environment's Python executable.
54+
if os.path.exists(python):
55+
if os.path.isdir(python):
56+
# bin/python for Unix, Scripts/python.exe for Windows
57+
# Try both in case of odd cases like cygwin.
58+
for exe in ("bin/python", "Scripts/python.exe"):
59+
py = os.path.join(python, exe)
60+
if os.path.exists(py):
61+
return py
62+
else:
63+
return python
64+
65+
# Could not find the interpreter specified
66+
return None
67+
68+
4869
def parse_command(args: List[str]) -> Tuple[str, List[str]]:
4970
parser = create_main_parser()
5071

@@ -57,6 +78,32 @@ def parse_command(args: List[str]) -> Tuple[str, List[str]]:
5778
# args_else: ['install', '--user', 'INITools']
5879
general_options, args_else = parser.parse_args(args)
5980

81+
# --python
82+
if general_options.python and "_PIP_RUNNING_IN_SUBPROCESS" not in os.environ:
83+
# Re-invoke pip using the specified Python interpreter
84+
interpreter = identify_python_interpreter(general_options.python)
85+
if interpreter is None:
86+
raise CommandError(
87+
f"Could not locate Python interpreter {general_options.python}"
88+
)
89+
90+
pip_cmd = [
91+
interpreter,
92+
get_runnable_pip(),
93+
]
94+
pip_cmd.extend(args)
95+
96+
# Set a flag so the child doesn't re-invoke itself, causing
97+
# an infinite loop.
98+
os.environ["_PIP_RUNNING_IN_SUBPROCESS"] = "1"
99+
returncode = 0
100+
try:
101+
proc = subprocess.run(pip_cmd)
102+
returncode = proc.returncode
103+
except (subprocess.SubprocessError, OSError) as exc:
104+
raise CommandError(f"Failed to run pip under {interpreter}: {exc}")
105+
sys.exit(returncode)
106+
60107
# --version
61108
if general_options.version:
62109
sys.stdout.write(parser.version)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import json
2+
import os
3+
from pathlib import Path
4+
from venv import EnvBuilder
5+
6+
from tests.lib import PipTestEnvironment, TestData
7+
8+
9+
def test_python_interpreter(
10+
script: PipTestEnvironment,
11+
tmpdir: Path,
12+
shared_data: TestData,
13+
) -> None:
14+
env_path = os.fspath(tmpdir / "venv")
15+
env = EnvBuilder(with_pip=False)
16+
env.create(env_path)
17+
18+
result = script.pip("--python", env_path, "list", "--format=json")
19+
before = json.loads(result.stdout)
20+
21+
# Ideally we would assert that before==[], but there's a problem in CI
22+
# that means this isn't true. See https://github.com/pypa/pip/pull/11326
23+
# for details.
24+
25+
script.pip(
26+
"--python",
27+
env_path,
28+
"install",
29+
"-f",
30+
shared_data.find_links,
31+
"--no-index",
32+
"simplewheel==1.0",
33+
)
34+
35+
result = script.pip("--python", env_path, "list", "--format=json")
36+
installed = json.loads(result.stdout)
37+
assert {"name": "simplewheel", "version": "1.0"} in installed
38+
39+
script.pip("--python", env_path, "uninstall", "simplewheel", "--yes")
40+
result = script.pip("--python", env_path, "list", "--format=json")
41+
assert json.loads(result.stdout) == before

tests/unit/test_cmdoptions.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import os
2+
from pathlib import Path
13
from typing import Optional, Tuple
4+
from venv import EnvBuilder
25

36
import pytest
47

58
from pip._internal.cli.cmdoptions import _convert_python_version
9+
from pip._internal.cli.main_parser import identify_python_interpreter
610

711

812
@pytest.mark.parametrize(
@@ -29,3 +33,20 @@ def test_convert_python_version(
2933
) -> None:
3034
actual = _convert_python_version(value)
3135
assert actual == expected, f"actual: {actual!r}"
36+
37+
38+
def test_identify_python_interpreter_venv(tmpdir: Path) -> None:
39+
env_path = tmpdir / "venv"
40+
env = EnvBuilder(with_pip=False)
41+
env.create(env_path)
42+
43+
# Passing a virtual environment returns the Python executable
44+
interp = identify_python_interpreter(os.fspath(env_path))
45+
assert interp is not None
46+
assert Path(interp).exists()
47+
48+
# Passing an executable returns it
49+
assert identify_python_interpreter(interp) == interp
50+
51+
# Passing a non-existent file returns None
52+
assert identify_python_interpreter(str(tmpdir / "nonexistent")) is None

0 commit comments

Comments
 (0)