Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/1663.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add `--fetch-python` with options "always", "missing", and "never".
Similarly add a corresponding `PIPX_FETCH_PYTHON` environment variable.
2 changes: 2 additions & 0 deletions changelog.d/1663.removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Deprecate `--fetch-missing-python`, alias it to `--fetch-python=missing`.
Similarly deprecate `PIPX_FETCH_MISSING_PYTHON` and alias its effects to `PIPX_FETCH_PYTHON="missing"`.
2 changes: 1 addition & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
pipx install pycowsay
pipx install --python python3.10 pycowsay
pipx install --python 3.12 pycowsay
pipx install --fetch-missing-python --python 3.12 pycowsay
pipx install --fetch-python=missing --python 3.12 pycowsay
pipx install git+https://github.com/psf/black
pipx install git+https://github.com/psf/black.git@branch-name
pipx install git+https://github.com/psf/black.git@git-hash
Expand Down
1 change: 1 addition & 0 deletions src/pipx/commands/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"PIPX_SHARED_LIBS",
"PIPX_DEFAULT_PYTHON",
"PIPX_FETCH_MISSING_PYTHON",
"PIPX_FETCH_PYTHON",
"PIPX_USE_EMOJI",
"PIPX_HOME_ALLOW_SPACE",
]
Expand Down
38 changes: 38 additions & 0 deletions src/pipx/constants.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,53 @@
import enum
import os
import platform
import sysconfig
from textwrap import dedent
from typing import NewType


# XXX: Python 3.11 StrEnum + enum.auto()
class FetchPythonOptions(str, enum.Enum):
ALWAYS = "always"
MISSING = "missing"
NEVER = "never"

def __str__(self):
return self.value


PIPX_SHARED_PTH = "pipx_shared.pth"
TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14
MINIMUM_PYTHON_VERSION = "3.9"
MAN_SECTIONS = ["man%d" % i for i in range(1, 10)]
FETCH_MISSING_PYTHON = os.environ.get("PIPX_FETCH_MISSING_PYTHON", False)

_FETCH_PYTHON_VALID = True
try:
FETCH_PYTHON = FetchPythonOptions(
os.environ.get("PIPX_FETCH_PYTHON", "missing" if FETCH_MISSING_PYTHON else "never")
)
except ValueError:
FETCH_PYTHON = FetchPythonOptions.NEVER
_FETCH_PYTHON_VALID = False


def _validate_fetch_python():
from pipx.util import PipxError

if not _FETCH_PYTHON_VALID:
raise PipxError(f"PIPX_FETCH_PYTHON must be unset or one of {{{', '.join(map(str, FetchPythonOptions))}}}.")
if "PIPX_FETCH_MISSING_PYTHON" in os.environ:
from warnings import warn

warn(
"The PIPX_FETCH_MISSING_PYTHON environment variable is deprecated and an"
f'alias for PIPX_FETCH_PYTHON="{FetchPythonOptions.MISSING}".',
stacklevel=2,
)
if "PIPX_FETCH_PYTHON" in os.environ:
raise PipxError("Setting both FETCH_MISSING_PYTHON and FETCH_PYTHON is invalid.")


ExitCode = NewType("ExitCode", int)
# pipx shell exit codes
Expand Down
23 changes: 15 additions & 8 deletions src/pipx/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from packaging import version

from pipx.constants import FETCH_MISSING_PYTHON, WINDOWS
from pipx.constants import WINDOWS, FetchPythonOptions
from pipx.standalone_python import download_python_build_standalone
from pipx.util import PipxError

Expand Down Expand Up @@ -83,7 +83,17 @@ def find_unix_command_python(python_version: str) -> Optional[str]:
return python_path


def find_python_interpreter(python_version: str, fetch_missing_python: bool = False) -> str:
def _fetch_standalone_interpreter(python_version: str):
try:
return download_python_build_standalone(python_version)
except PipxError as e:
raise InterpreterResolutionError(source="the python-build-standalone project", version=python_version) from e


def find_python_interpreter(python_version: str, fetch_python: FetchPythonOptions = FetchPythonOptions.NEVER) -> str:
if fetch_python == FetchPythonOptions.ALWAYS:
return _fetch_standalone_interpreter(python_version)

if Path(python_version).is_file() or shutil.which(python_version):
return python_version

Expand All @@ -97,14 +107,11 @@ def find_python_interpreter(python_version: str, fetch_missing_python: bool = Fa
if py_executable:
return py_executable
except (subprocess.CalledProcessError, FileNotFoundError) as e:
if not fetch_missing_python and not FETCH_MISSING_PYTHON:
if fetch_python != FetchPythonOptions.MISSING:
raise InterpreterResolutionError(source="py launcher", version=python_version) from e

if fetch_missing_python or FETCH_MISSING_PYTHON:
try:
return download_python_build_standalone(python_version)
except PipxError as e:
raise InterpreterResolutionError(source="the python-build-standalone project", version=python_version) from e
if fetch_python == FetchPythonOptions.MISSING:
return _fetch_standalone_interpreter(python_version)

raise InterpreterResolutionError(source="PATH", version=python_version)

Expand Down
28 changes: 20 additions & 8 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@
from pipx.constants import (
EXIT_CODE_OK,
EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND,
FETCH_PYTHON,
MINIMUM_PYTHON_VERSION,
WINDOWS,
ExitCode,
FetchPythonOptions,
_validate_fetch_python,
)
from pipx.emojis import hazard
from pipx.interpreter import (
Expand Down Expand Up @@ -247,11 +250,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar

if "python" in args:
python_flag_passed = bool(args.python)
fetch_missing_python = args.fetch_missing_python
try:
interpreter = find_python_interpreter(
args.python or DEFAULT_PYTHON, fetch_missing_python=fetch_missing_python
)
interpreter = find_python_interpreter(args.python or DEFAULT_PYTHON, args.fetch_python)
args.python = interpreter
except InterpreterResolutionError as e:
logger.debug("Failed to resolve interpreter:", exc_info=True)
Expand Down Expand Up @@ -459,13 +459,24 @@ def add_python_options(parser: argparse.ArgumentParser) -> None:
f"or the full path to the executable. Requires Python {MINIMUM_PYTHON_VERSION} or above."
),
)
parser.add_argument(
"--fetch-missing-python",
action="store_true",
fetch_python_group = parser.add_mutually_exclusive_group()
fetch_python_group.add_argument(
"--fetch-python",
type=FetchPythonOptions,
choices=list(FetchPythonOptions),
default=FETCH_PYTHON,
help=(
"Whether to fetch a standalone python build from GitHub if the specified python version is not found locally on the system."
f"Whether to fetch a standalone python build from GitHub. If set to {FetchPythonOptions.MISSING}, "
"only downloads if the specified python version is not found locally on the system."
"Defaults to value of the PIPX_FETCH_PYTHON environment variable."
),
)
fetch_python_group.add_argument(
"--fetch-missing-python",
action="store_const",
const=FetchPythonOptions.MISSING,
help="Deprecated: Alias for --fetch-python=missing",
)


def _add_install(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
Expand Down Expand Up @@ -1170,6 +1181,7 @@ def cli() -> ExitCode:
"""Entry point from command line"""
try:
hide_cursor()
_validate_fetch_python()
parser, subparsers = get_command_parser()
argcomplete.autocomplete(parser)
parsed_pipx_args = parser.parse_args()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ def test_passed_python_and_force_flag_warning(pipx_temp_env, capsys):
["3.0", "3.1"],
)
def test_install_fetch_missing_python_invalid(capsys, python_version):
assert run_pipx_cli(["install", "--python", python_version, "--fetch-missing-python", "pycowsay"])
assert run_pipx_cli(["install", "--python", python_version, "--fetch-python=missing", "pycowsay"])
captured = capsys.readouterr()
assert f"No executable for the provided Python version '{python_version}' found" in captured.out

Expand Down
4 changes: 2 additions & 2 deletions tests/test_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pipx.interpreter
import pipx.paths
import pipx.standalone_python
from pipx.constants import WINDOWS
from pipx.constants import WINDOWS, FetchPythonOptions
from pipx.interpreter import (
InterpreterResolutionError,
_find_default_windows_python,
Expand Down Expand Up @@ -190,7 +190,7 @@ def which(name):
minor = sys.version_info.minor
target_python = f"{major}.{minor}"

python_path = find_python_interpreter(target_python, fetch_missing_python=True)
python_path = find_python_interpreter(target_python, fetch_python=FetchPythonOptions.MISSING)
assert python_path is not None
assert target_python in python_path
assert str(pipx.paths.ctx.standalone_python_cachedir) in python_path
Expand Down
2 changes: 1 addition & 1 deletion tests/test_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def which(name):
assert not run_pipx_cli(
[
"install",
"--fetch-missing-python",
"--fetch-python=missing",
"--python",
target_python,
PKG["pycowsay"]["spec"],
Expand Down
8 changes: 4 additions & 4 deletions tests/test_standalone_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def test_list_used_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_gi
assert not run_pipx_cli(
[
"install",
"--fetch-missing-python",
"--fetch-python=missing",
"--python",
TARGET_PYTHON_VERSION,
PKG["pycowsay"]["spec"],
Expand All @@ -56,7 +56,7 @@ def test_list_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked_
assert not run_pipx_cli(
[
"install",
"--fetch-missing-python",
"--fetch-python=missing",
"--python",
TARGET_PYTHON_VERSION,
PKG["pycowsay"]["spec"],
Expand All @@ -79,7 +79,7 @@ def test_prune_unused_standalone_interpreters(pipx_temp_env, monkeypatch, mocked
assert not run_pipx_cli(
[
"install",
"--fetch-missing-python",
"--fetch-python=missing",
"--python",
TARGET_PYTHON_VERSION,
PKG["pycowsay"]["spec"],
Expand Down Expand Up @@ -119,7 +119,7 @@ def test_upgrade_standalone_interpreter(pipx_temp_env, root, monkeypatch, capsys
assert not run_pipx_cli(
[
"install",
"--fetch-missing-python",
"--fetch-python=missing",
"--python",
TARGET_PYTHON_VERSION,
PKG["pycowsay"]["spec"],
Expand Down