diff --git a/changelog.d/1663.feature.md b/changelog.d/1663.feature.md new file mode 100644 index 000000000..41ea19b66 --- /dev/null +++ b/changelog.d/1663.feature.md @@ -0,0 +1,2 @@ +Add `--fetch-python` with options "always", "missing", and "never". +Similarly add a corresponding `PIPX_FETCH_PYTHON` environment variable. diff --git a/changelog.d/1663.removal.md b/changelog.d/1663.removal.md new file mode 100644 index 000000000..26541e8dc --- /dev/null +++ b/changelog.d/1663.removal.md @@ -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"`. diff --git a/docs/examples.md b/docs/examples.md index d14537bf5..dc1208be6 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 diff --git a/src/pipx/commands/environment.py b/src/pipx/commands/environment.py index 15be666a4..00393cf78 100644 --- a/src/pipx/commands/environment.py +++ b/src/pipx/commands/environment.py @@ -16,6 +16,7 @@ "PIPX_SHARED_LIBS", "PIPX_DEFAULT_PYTHON", "PIPX_FETCH_MISSING_PYTHON", + "PIPX_FETCH_PYTHON", "PIPX_USE_EMOJI", "PIPX_HOME_ALLOW_SPACE", ] diff --git a/src/pipx/constants.py b/src/pipx/constants.py index 865a8f000..78a54b9bf 100644 --- a/src/pipx/constants.py +++ b/src/pipx/constants.py @@ -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 = [f"man{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 diff --git a/src/pipx/interpreter.py b/src/pipx/interpreter.py index e7d3ddb3b..b7b714e59 100644 --- a/src/pipx/interpreter.py +++ b/src/pipx/interpreter.py @@ -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 @@ -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 @@ -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) diff --git a/src/pipx/main.py b/src/pipx/main.py index 9359f528c..b9607ac30 100644 --- a/src/pipx/main.py +++ b/src/pipx/main.py @@ -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 ( @@ -263,11 +266,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) @@ -476,13 +476,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: @@ -1200,6 +1211,7 @@ def cli() -> ExitCode: """Entry point from command line""" try: hide_cursor() + _validate_fetch_python() parser, subparsers = get_command_parser() argcomplete.autocomplete(parser, always_complete_options=False) parsed_pipx_args = parser.parse_args() diff --git a/tests/test_install.py b/tests/test_install.py index f068d66d9..91870dbb8 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -428,7 +428,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 diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index c1f76b750..83630756f 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -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, @@ -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 diff --git a/tests/test_list.py b/tests/test_list.py index d3a060366..13480b8a2 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -168,7 +168,7 @@ def which(name): assert not run_pipx_cli( [ "install", - "--fetch-missing-python", + "--fetch-python=missing", "--python", target_python, PKG["pycowsay"]["spec"], diff --git a/tests/test_standalone_interpreter.py b/tests/test_standalone_interpreter.py index 8a578805e..b07516cfb 100644 --- a/tests/test_standalone_interpreter.py +++ b/tests/test_standalone_interpreter.py @@ -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"], @@ -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"], @@ -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"], @@ -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"],