From c4f35c8ce6c0a77e859d08ab531cc7c49c7c51ed Mon Sep 17 00:00:00 2001 From: "Xuan (Sean) Hu" Date: Sat, 27 Apr 2024 17:52:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20support=20Python=20version=20for=20`--p?= =?UTF-8?q?ython`=20arg=20when=20py=20launcher=20is=20n=E2=80=A6=20(#1343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * minor --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: chrysle --- changelog.d/1342.feature.md | 1 + src/pipx/interpreter.py | 48 ++++++++++++++++++++++++++---- src/pipx/main.py | 4 +-- tests/test_install.py | 58 +++++++++++++++++++++++++++++++++++++ tests/test_interpreter.py | 14 +++++++-- 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 changelog.d/1342.feature.md diff --git a/changelog.d/1342.feature.md b/changelog.d/1342.feature.md new file mode 100644 index 0000000000..d9812a7bfc --- /dev/null +++ b/changelog.d/1342.feature.md @@ -0,0 +1 @@ +Support Python version for `--python` arg when py launcher is not available diff --git a/src/pipx/interpreter.py b/src/pipx/interpreter.py index cbd5fb3ffe..f4d80f56c1 100644 --- a/src/pipx/interpreter.py +++ b/src/pipx/interpreter.py @@ -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 @@ -41,17 +43,56 @@ 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: @@ -59,9 +100,6 @@ def find_python_interpreter(python_version: str, fetch_missing_python: bool = Fa 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) diff --git a/src/pipx/main.py b/src/pipx/main.py index fe2b50a4cf..42de8746da 100644 --- a/src/pipx/main.py +++ b/src/pipx/main.py @@ -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( diff --git a/tests/test_install.py b/tests/test_install.py index 268c297afd..f862400069 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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 diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py index 446b69c0aa..6201646d89 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -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 @@ -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 @@ -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