Skip to content

Commit 7e648ec

Browse files
committed
feat: Support always fetching a standalone python interpreter
There exist environments where users need known "good" python interpreters to run their commands from. Notably, linux distros _can_ package non-standard alterations to their Python interpreters, or not have certain "optional" features. This commit adds `--fetch-python={always, missing, never}` and the associated environment variable `PIPX_FETCH_PYTHON`. `--fetch-missing-python` has been deprecated and aliased to `--fetch-python=missing`. The corresponding change has been made to `PIPX_FETCH_MISSING_PYTHON`.
1 parent 33f37fc commit 7e648ec

File tree

11 files changed

+83
-25
lines changed

11 files changed

+83
-25
lines changed

changelog.d/XXX.feature.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add `--fetch-python` with options "always", "missing", and "never".
2+
Similarly add a corresponding `PIPX_FETCH_PYTHON` environment variable.

changelog.d/XXX.removal.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Deprecate `--fetch-missing-python`, alias it to `--fetch-python=missing`.
2+
Similarly deprecate `PIPX_FETCH_MISSING_PYTHON` and alias its effects to `PIPX_FETCH_PYTHON="missing"`.

docs/examples.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
pipx install pycowsay
55
pipx install --python python3.10 pycowsay
66
pipx install --python 3.12 pycowsay
7-
pipx install --fetch-missing-python --python 3.12 pycowsay
7+
pipx install --fetch-python=missing --python 3.12 pycowsay
88
pipx install git+https://github.com/psf/black
99
pipx install git+https://github.com/psf/black.git@branch-name
1010
pipx install git+https://github.com/psf/black.git@git-hash

src/pipx/commands/environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"PIPX_SHARED_LIBS",
1717
"PIPX_DEFAULT_PYTHON",
1818
"PIPX_FETCH_MISSING_PYTHON",
19+
"PIPX_FETCH_PYTHON",
1920
"PIPX_USE_EMOJI",
2021
"PIPX_HOME_ALLOW_SPACE",
2122
]

src/pipx/constants.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,49 @@
1+
import enum
12
import os
23
import platform
34
import sysconfig
45
from textwrap import dedent
56
from typing import NewType
67

8+
9+
class FetchPythonOptions(enum.StrEnum):
10+
ALWAYS = enum.auto()
11+
MISSING = enum.auto()
12+
NEVER = enum.auto()
13+
14+
715
PIPX_SHARED_PTH = "pipx_shared.pth"
816
TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14
917
MINIMUM_PYTHON_VERSION = "3.9"
1018
MAN_SECTIONS = ["man%d" % i for i in range(1, 10)]
1119
FETCH_MISSING_PYTHON = os.environ.get("PIPX_FETCH_MISSING_PYTHON", False)
1220

21+
_FETCH_PYTHON_VALID = True
22+
try:
23+
FETCH_PYTHON = FetchPythonOptions(
24+
os.environ.get("PIPX_FETCH_PYTHON", "missing" if FETCH_MISSING_PYTHON else "never")
25+
)
26+
except ValueError:
27+
FETCH_PYTHON = FetchPythonOptions.NEVER
28+
_FETCH_PYTHON_VALID = False
29+
30+
31+
def _validate_fetch_python():
32+
from pipx.util import PipxError
33+
34+
if not _FETCH_PYTHON_VALID:
35+
raise PipxError(f"PIPX_FETCH_PYTHON must be unset or one of {{{', '.join(map(str, FetchPythonOptions))}}}.")
36+
if "PIPX_FETCH_MISSING_PYTHON" in os.environ:
37+
from warnings import warn
38+
39+
warn(
40+
"The PIPX_FETCH_MISSING_PYTHON environment variable is deprecated and an"
41+
f'alias for PIPX_FETCH_PYTHON="{FetchPythonOptions.MISSING}".',
42+
stacklevel=2,
43+
)
44+
if "PIPX_FETCH_PYTHON" in os.environ:
45+
raise PipxError("Setting both FETCH_MISSING_PYTHON and FETCH_PYTHON is invalid.")
46+
1347

1448
ExitCode = NewType("ExitCode", int)
1549
# pipx shell exit codes

src/pipx/interpreter.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from packaging import version
1010

11-
from pipx.constants import FETCH_MISSING_PYTHON, WINDOWS
11+
from pipx.constants import WINDOWS, FetchPythonOptions
1212
from pipx.standalone_python import download_python_build_standalone
1313
from pipx.util import PipxError
1414

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

8585

86-
def find_python_interpreter(python_version: str, fetch_missing_python: bool = False) -> str:
86+
def _fetch_standalone_interpreter(python_version: str):
87+
try:
88+
return download_python_build_standalone(python_version)
89+
except PipxError as e:
90+
raise InterpreterResolutionError(source="the python-build-standalone project", version=python_version) from e
91+
92+
93+
def find_python_interpreter(python_version: str, fetch_python: FetchPythonOptions = FetchPythonOptions.NEVER) -> str:
94+
if fetch_python == FetchPythonOptions.ALWAYS:
95+
return _fetch_standalone_interpreter(python_version)
96+
8797
if Path(python_version).is_file() or shutil.which(python_version):
8898
return python_version
8999

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

103-
if fetch_missing_python or FETCH_MISSING_PYTHON:
104-
try:
105-
return download_python_build_standalone(python_version)
106-
except PipxError as e:
107-
raise InterpreterResolutionError(source="the python-build-standalone project", version=python_version) from e
113+
if fetch_python == FetchPythonOptions.MISSING:
114+
return _fetch_standalone_interpreter(python_version)
108115

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

src/pipx/main.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@
2626
from pipx.constants import (
2727
EXIT_CODE_OK,
2828
EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND,
29+
FETCH_PYTHON,
2930
MINIMUM_PYTHON_VERSION,
3031
WINDOWS,
3132
ExitCode,
33+
FetchPythonOptions,
34+
_validate_fetch_python,
3235
)
3336
from pipx.emojis import hazard
3437
from pipx.interpreter import (
@@ -247,11 +250,8 @@ def run_pipx_command(args: argparse.Namespace, subparsers: Dict[str, argparse.Ar
247250

248251
if "python" in args:
249252
python_flag_passed = bool(args.python)
250-
fetch_missing_python = args.fetch_missing_python
251253
try:
252-
interpreter = find_python_interpreter(
253-
args.python or DEFAULT_PYTHON, fetch_missing_python=fetch_missing_python
254-
)
254+
interpreter = find_python_interpreter(args.python or DEFAULT_PYTHON, args.fetch_python)
255255
args.python = interpreter
256256
except InterpreterResolutionError as e:
257257
logger.debug("Failed to resolve interpreter:", exc_info=True)
@@ -459,13 +459,24 @@ def add_python_options(parser: argparse.ArgumentParser) -> None:
459459
f"or the full path to the executable. Requires Python {MINIMUM_PYTHON_VERSION} or above."
460460
),
461461
)
462-
parser.add_argument(
463-
"--fetch-missing-python",
464-
action="store_true",
462+
fetch_python_group = parser.add_mutually_exclusive_group()
463+
fetch_python_group.add_argument(
464+
"--fetch-python",
465+
type=FetchPythonOptions,
466+
choices=list(FetchPythonOptions),
467+
default=FETCH_PYTHON,
465468
help=(
466-
"Whether to fetch a standalone python build from GitHub if the specified python version is not found locally on the system."
469+
f"Whether to fetch a standalone python build from GitHub. If set to {FetchPythonOptions.MISSING}, "
470+
"only downloads if the specified python version is not found locally on the system."
471+
"Defaults to value of the PIPX_FETCH_PYTHON environment variable."
467472
),
468473
)
474+
fetch_python_group.add_argument(
475+
"--fetch-missing-python",
476+
action="store_const",
477+
const=FetchPythonOptions.MISSING,
478+
help="Deprecated: Alias for --fetch-python=missing",
479+
)
469480

470481

471482
def _add_install(subparsers: argparse._SubParsersAction, shared_parser: argparse.ArgumentParser) -> None:
@@ -1170,6 +1181,7 @@ def cli() -> ExitCode:
11701181
"""Entry point from command line"""
11711182
try:
11721183
hide_cursor()
1184+
_validate_fetch_python()
11731185
parser, subparsers = get_command_parser()
11741186
argcomplete.autocomplete(parser)
11751187
parsed_pipx_args = parser.parse_args()

tests/test_install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ def test_passed_python_and_force_flag_warning(pipx_temp_env, capsys):
425425
["3.0", "3.1"],
426426
)
427427
def test_install_fetch_missing_python_invalid(capsys, python_version):
428-
assert run_pipx_cli(["install", "--python", python_version, "--fetch-missing-python", "pycowsay"])
428+
assert run_pipx_cli(["install", "--python", python_version, "--fetch-python=missing", "pycowsay"])
429429
captured = capsys.readouterr()
430430
assert f"No executable for the provided Python version '{python_version}' found" in captured.out
431431

tests/test_interpreter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pipx.interpreter
99
import pipx.paths
1010
import pipx.standalone_python
11-
from pipx.constants import WINDOWS
11+
from pipx.constants import WINDOWS, FetchPythonOptions
1212
from pipx.interpreter import (
1313
InterpreterResolutionError,
1414
_find_default_windows_python,
@@ -190,7 +190,7 @@ def which(name):
190190
minor = sys.version_info.minor
191191
target_python = f"{major}.{minor}"
192192

193-
python_path = find_python_interpreter(target_python, fetch_missing_python=True)
193+
python_path = find_python_interpreter(target_python, fetch_python=FetchPythonOptions.MISSING)
194194
assert python_path is not None
195195
assert target_python in python_path
196196
assert str(pipx.paths.ctx.standalone_python_cachedir) in python_path

tests/test_list.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def which(name):
170170
assert not run_pipx_cli(
171171
[
172172
"install",
173-
"--fetch-missing-python",
173+
"--fetch-python=missing",
174174
"--python",
175175
target_python,
176176
PKG["pycowsay"]["spec"],

0 commit comments

Comments
 (0)