Skip to content

Commit

Permalink
Merge pull request #11320 from pfmoore/python_option
Browse files Browse the repository at this point in the history
Add a --python option
  • Loading branch information
pfmoore authored Aug 6, 2022
2 parents 8070892 + 9b638ec commit 9473e83
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 3 deletions.
1 change: 1 addition & 0 deletions docs/html/topics/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ local-project-installs
repeatable-installs
secure-installs
vcs-support
python-option
```
29 changes: 29 additions & 0 deletions docs/html/topics/python-option.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions news/11320.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add a ``--python`` option to allow pip to manage Python environments other
than the one pip is installed in.
4 changes: 2 additions & 2 deletions src/pip/_internal/build_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,7 +194,7 @@ def install_requirements(
if not requirements:
return
self._install_requirements(
_get_runnable_pip(),
get_runnable_pip(),
finder,
requirements,
prefix,
Expand Down
8 changes: 8 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1029,6 +1036,7 @@ def check_list_path_option(options: Values) -> None:
debug_mode,
isolated_mode,
require_virtualenv,
python,
verbose,
version,
quiet,
Expand Down
49 changes: 48 additions & 1 deletion src/pip/_internal/cli/main_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions tests/functional/test_python_option.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions tests/unit/test_cmdoptions.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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

0 comments on commit 9473e83

Please sign in to comment.