Skip to content

Commit

Permalink
feat: support Python version for --python arg when py launcher is n… (
Browse files Browse the repository at this point in the history
#1343)

* feat: support Python version for `--python` arg when py launcher is not available

* fix test

* update help message

* fix test

* update test

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix test

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix test

* Apply suggestions from code review

Co-authored-by: chrysle <[email protected]>

* refine the tests

* Simplify micro version check to warning

* fix test

* return python_path instead of python_command

* fix test

* Apply suggestions from code review

Co-authored-by: chrysle <[email protected]>

* revert changes in test_interpreter.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update src/pipx/interpreter.py

Co-authored-by: chrysle <[email protected]>

* minor

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: chrysle <[email protected]>
  • Loading branch information
3 people authored Apr 27, 2024
1 parent c44d210 commit c4f35c8
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 10 deletions.
1 change: 1 addition & 0 deletions changelog.d/1342.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support Python version for `--python` arg when py launcher is not available
48 changes: 43 additions & 5 deletions src/pipx/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from pathlib import Path
from typing import Optional

from packaging import version

from pipx.constants import FETCH_MISSING_PYTHON, WINDOWS
from pipx.standalone_python import download_python_build_standalone
from pipx.util import PipxError
Expand Down Expand Up @@ -41,27 +43,63 @@ def __init__(self, source: str, version: str, wrap_message: bool = True):
message += "The provided version looks like a path, but no executable was found there."
if potentially_pylauncher:
message += (
"The provided version looks like a version for Python Launcher, " "but `py` was not found on PATH."
"The provided version looks like a version, "
"but both the python command and the Python Launcher were not found on PATH."
)
if source == "the python-build-standalone project":
message += "listed in https://github.com/indygreg/python-build-standalone/releases/latest."
super().__init__(message, wrap_message)


def find_unix_command_python(python_version: str) -> Optional[str]:
try:
parsed_python_version = version.parse(python_version)
except version.InvalidVersion:
logger.info(f"Invalid Python version: {python_version}")
return None

if (
parsed_python_version.epoch != 0
or parsed_python_version.is_devrelease
or parsed_python_version.is_postrelease
or parsed_python_version.is_prerelease
):
logger.info(f"Unsupported Python version: {python_version}")
return None

# Python command could be `python3` or `python3.x` without micro version component
python_command = f"python{'.'.join(python_version.split('.')[:2])}"

python_path = shutil.which(python_command)
if not python_path:
logger.info(f"Command `{python_command}` was not found on the system")
return None

if parsed_python_version.micro != 0:
logger.warning(
f"The command '{python_command}' located at '{python_path}' will be used. "
f"It may not match the specified version {python_version} at the micro/patch level."
)

return python_path


def find_python_interpreter(python_version: str, fetch_missing_python: bool = False) -> str:
if Path(python_version).is_file():
if Path(python_version).is_file() or shutil.which(python_version):
return python_version

if not WINDOWS:
python_unix_command = find_unix_command_python(python_version)
if python_unix_command:
return python_unix_command

try:
py_executable = find_py_launcher_python(python_version)
if py_executable:
return py_executable
except (subprocess.CalledProcessError, FileNotFoundError) as e:
raise InterpreterResolutionError(source="py launcher", version=python_version) from e

if shutil.which(python_version):
return python_version

if fetch_missing_python or FETCH_MISSING_PYTHON:
try:
standalone_executable = download_python_build_standalone(python_version)
Expand Down
4 changes: 2 additions & 2 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,8 @@ def add_python_options(parser: argparse.ArgumentParser) -> None:
"--python",
help=(
"Python to install with. Possible values can be the executable name (python3.11), "
"the version to pass to py launcher (3.11), or the full path to the executable."
f"Requires Python {MINIMUM_PYTHON_VERSION} or above."
"the version of an available system Python or to pass to py launcher (3.11), "
f"or the full path to the executable. Requires Python {MINIMUM_PYTHON_VERSION} or above."
),
)
parser.add_argument(
Expand Down
58 changes: 58 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,61 @@ def test_install_fetch_missing_python_invalid(capsys, python_version):
assert run_pipx_cli(["install", "--python", python_version, "--fetch-missing-python", "pycowsay"])
captured = capsys.readouterr()
assert f"No executable for the provided Python version '{python_version}' found" in captured.out


def test_install_run_in_separate_directory(caplog, capsys, pipx_temp_env, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
f = Path("argparse.py")
f.touch()

install_packages(capsys, pipx_temp_env, caplog, ["pycowsay"], ["pycowsay"])


@skip_if_windows
@pytest.mark.parametrize(
"python_version",
[
str(sys.version_info.major),
f"{sys.version_info.major}.{sys.version_info.minor}",
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
],
)
def test_install_python_command_version(pipx_temp_env, monkeypatch, capsys, python_version):
monkeypatch.setenv("PATH", os.getenv("PATH_ORIG"))
assert not run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"])
captured = capsys.readouterr()
assert python_version in captured.out


@skip_if_windows
def test_install_python_command_version_invalid(pipx_temp_env, capsys):
python_version = "3.x"
assert run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"])
captured = capsys.readouterr()
assert f"Invalid Python version: {python_version}" in captured.err


@skip_if_windows
def test_install_python_command_version_unsupported(pipx_temp_env, capsys):
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}.dev"
assert run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"])
captured = capsys.readouterr()
assert f"Unsupported Python version: {python_version}" in captured.err


@skip_if_windows
def test_install_python_command_version_missing(pipx_temp_env, monkeypatch, capsys):
monkeypatch.setenv("PATH", os.getenv("PATH_ORIG"))
python_version = f"{sys.version_info.major + 99}.{sys.version_info.minor}"
assert run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"])
captured = capsys.readouterr()
assert f"Command `python{python_version}` was not found" in captured.err


@skip_if_windows
def test_install_python_command_version_micro_mismatch(pipx_temp_env, monkeypatch, capsys):
monkeypatch.setenv("PATH", os.getenv("PATH_ORIG"))
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro + 1}"
assert not run_pipx_cli(["install", "--python", python_version, "--verbose", "pycowsay"])
captured = capsys.readouterr()
assert f"It may not match the specified version {python_version} at the micro/patch level" in captured.err
14 changes: 11 additions & 3 deletions tests/test_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@
)
from pipx.util import PipxError

original_which = shutil.which


@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Looks for Python.exe")
@pytest.mark.parametrize("venv", [True, False])
def test_windows_python_with_version(monkeypatch, venv):
def which(name):
return "py"
if name == "py":
return "py"
return original_which(name)

major = sys.version_info.major
minor = sys.version_info.minor
Expand All @@ -38,7 +42,9 @@ def which(name):
@pytest.mark.parametrize("venv", [True, False])
def test_windows_python_with_python_and_version(monkeypatch, venv):
def which(name):
return "py"
if name == "py":
return "py"
return original_which(name)

major = sys.version_info.major
minor = sys.version_info.minor
Expand All @@ -54,7 +60,9 @@ def which(name):
@pytest.mark.parametrize("venv", [True, False])
def test_windows_python_with_python_and_unavailable_version(monkeypatch, venv):
def which(name):
return "py"
if name == "py":
return "py"
return original_which(name)

major = sys.version_info.major + 99
minor = sys.version_info.minor
Expand Down

0 comments on commit c4f35c8

Please sign in to comment.