Repository: pypa/pipx (python)

Issue:
Support Python version number for `--python` arg when py launcher is not available
**How would this feature be useful?**

<!-- Describe any use cases this solves or frustrations it alleviates.   -->

Currently, if we want to specify the python when py launcher is not available, we have to specify the whole command name, for example, we have to use `--python python3.12` rather than `--python 3.12`. It would be more convenient if we can check `python3.12` directly when the input value is `3.12`.

**Describe the solution you'd like**

<!-- If you have an idea of how to do this, write it here! -->

We can use `packaging.version` module to parse the input value and then compose the command f"python{python_version}" if it is a valid version number.

**Describe alternatives you've considered**

<!-- If there's some workaround or alternative solutions, let us know. -->

AFAIK, there is none.

BTW, I can submit a pull request if we agree on this change.


Pull request description:
feat: support Python version for `--python` arg when py launcher is n…
…ot available

<!-- add an 'x' in the brackets below -->

- [x] I have added a news fragment under `changelog.d/` (if the patch affects the end users)

## Summary of changes

Close #1342

1. Add `find_python_command()` to find python command according to provided python version.
2. Add corresponding tests for `pipx install` and `find_python_interpreter()`.

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Tested by running

```
# command(s) to exercise these changes
nox -s tests
```

BTW, I am a little confused about the fixture `pipx_temp_env`, seems `shutil.which(python_command)` will always return empty result when it is enabled. So I removed it in some related test cases. Please help me to double confirm whether it is used as expected.


Patch:
diff --git a/changelog.d/1342.feature.md b/changelog.d/1342.feature.md
new file mode 100644
index 0000000..d9812a7
--- /dev/null
+++ b/changelog.d/1342.feature.md
@@ -0,0 +1,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 cbd5fb3..f4d80f5 100644
--- a/src/pipx/interpreter.py
+++ b/src/pipx/interpreter.py
@@ -6,6 +6,8 @@ import sys
 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 @@ class InterpreterResolutionError(PipxError):
                 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 fe2b50a..42de874 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(


Test patch:
diff --git a/tests/test_install.py b/tests/test_install.py
index 268c297..f862400 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 446b69c..6201646 100644
--- a/tests/test_interpreter.py
+++ b/tests/test_interpreter.py
@@ -17,12 +17,16 @@ from pipx.interpreter import (
 )
 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 test_windows_python_with_version(monkeypatch, venv):
 @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 test_windows_python_with_python_and_version(monkeypatch, venv):
 @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