Repository: pypa/pipx (python)

Issue:
--python python3.11 results in an error
**Describe the bug**

- The output of `--help` explains that `pipx run` supports  `--python python3.11`.
- My testing shows that this results in an error
- I tested on 1.3.3 and the latest version from main
- A workaround is to use `--python 3.11`
- *Edit* I tested with the `python-launcher` package installed on Fedora 38

**How to reproduce**

Command to reproduce this bug:

    pipx run --python python3.11 pycowsay moo

Error message:

    Traceback (most recent call last):
      File "/home/maxwell-k/.local/bin/pipx", line 8, in <module>
        sys.exit(cli())
                 ^^^^^
      File "/home/maxwell-k/.local/pipx/venvs/pipx/lib64/python3.11/site-packages/pipx/main.py", line 885, in cli
        return run_pipx_command(parsed_pipx_args)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/home/maxwell-k/.local/pipx/venvs/pipx/lib64/python3.11/site-packages/pipx/main.py", line 192, in run_pipx_command
        py_launcher_python = find_py_launcher_python(args.python)
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/home/maxwell-k/.local/pipx/venvs/pipx/lib64/python3.11/site-packages/pipx/interpreter.py", line 33, in find_py_launcher_python
        py = subprocess.run(
             ^^^^^^^^^^^^^^^
      File "/usr/lib64/python3.11/subprocess.py", line 571, in run
        raise CalledProcessError(retcode, process.args,
    subprocess.CalledProcessError: Command '['/usr/bin/py', '-python3.11', '-c', 'import sys; print(sys.executable)']' returned non-zero exit status 2.

Command to show `pipx` version:

    pipx --version

Output:

    1.3.3

Also tested with:

    1.3.4.dev8+gb66335c

**Expected behavior**

Command to show help:

    pipx run --help

Excerpt from help:

```
  --python PYTHON       Python to run 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. Requires Python 3.8 or
                        above.
```

I therefore expect the two commands below to be equivalent:

    pipx run --python python3.11 pycowsay moo
    pipx run --python 3.11 pycowsay moo

They should both display a mooing cow:

      ---
    < moo >
      ---
       \   ^__^
        \  (oo)\_______
           (__)\       )\/\
               ||----w |
               ||     ||


Pull request:
Potential fix for pylauncher with python version
<!-- add an 'x' in the brackets below -->

- [X] I have added an entry to `docs/changelog.md`

## Summary of changes

When pylauncher is detected, check the provided python version to see if it was provided including `python` (as per our examples). If so, log a warning and remove python from the version, leaving only the semver, as expected by pylauncher.

This might fix #1150

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Not tested yet, as I'm not on windows and I don't have pylauncher. I've added an automated test that might verify the new behavior. 
Maybe someone using pylauncher can assist me here with a manual validation.

```
# command(s) to exercise these changes
```


Patch:
diff --git a/changelog.d/1150.bugfix.md b/changelog.d/1150.bugfix.md
new file mode 100644
index 0000000..33dfdb8
--- /dev/null
+++ b/changelog.d/1150.bugfix.md
@@ -0,0 +1,1 @@
+Autofix python version for pylauncher, when version is provided prefixed with `python`
diff --git a/changelog.d/1150.doc.md b/changelog.d/1150.doc.md
new file mode 100644
index 0000000..31acfda
--- /dev/null
+++ b/changelog.d/1150.doc.md
@@ -0,0 +1,1 @@
+Provide useful error messages when unresolvable python version is passed
diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py
index 6b34f53..860b3f2 100644
--- a/src/pipx/commands/install.py
+++ b/src/pipx/commands/install.py
@@ -1,12 +1,11 @@
 from pathlib import Path
 from typing import List, Optional
 
-from pipx import constants, emojis
+from pipx import constants
 from pipx.commands.common import package_name_from_spec, run_post_install_actions
 from pipx.constants import (
     EXIT_CODE_INSTALL_VENV_EXISTS,
     EXIT_CODE_OK,
-    EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND,
     ExitCode,
 )
 from pipx.interpreter import DEFAULT_PYTHON
@@ -107,23 +106,6 @@ def install(
                 include_dependencies,
                 force=force,
             )
-        except FileNotFoundError as e:
-            venv.remove_venv()
-            if python in str(e) or "The system cannot find the file specified" in str(e):
-                print(
-                    pipx_wrap(
-                        f"""
-                           {emojis.hazard} No executable for the provided Python version '{python}' found.
-                           Please make sure the executable name is on your PATH /
-                           the path to the executable is correct.
-                        """,
-                        subsequent_indent=" " * 4,
-                    )
-                )
-                return EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND
-            else:
-                print()
-                raise
         except (Exception, KeyboardInterrupt):
             print()
             venv.remove_venv()
diff --git a/src/pipx/interpreter.py b/src/pipx/interpreter.py
index 0b29185..29d45ce 100644
--- a/src/pipx/interpreter.py
+++ b/src/pipx/interpreter.py
@@ -1,12 +1,16 @@
+import logging
 import os
 import shutil
 import subprocess
 import sys
+from pathlib import Path
 from typing import Optional
 
 from pipx.constants import WINDOWS
 from pipx.util import PipxError
 
+logger = logging.getLogger(__name__)
+
 
 def has_venv() -> bool:
     try:
@@ -17,6 +21,46 @@ def has_venv() -> bool:
         return False
 
 
+class InterpreterResolutionError(PipxError):
+    def __init__(self, source: str, version: str, wrap_message: bool = True):
+        self.source = source
+        self.version = version
+        potentially_path = "/" in version
+        potentially_pylauncher = "python" not in version and not potentially_path
+
+        message = (
+            f"No executable for the provided Python version '{version}' found in {source}."
+            " Please make sure the provided version is "
+        )
+        if source == "py launcher":
+            message += "listed when running `py --list`."
+        if source == "PATH":
+            message += "on your PATH or the file path is valid. "
+            if potentially_path:
+                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."
+                )
+        super().__init__(message, wrap_message)
+
+
+def find_python_interpreter(python_version: str) -> str:
+    if Path(python_version).is_file():
+        return python_version
+
+    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
+    raise InterpreterResolutionError(source="PATH", version=python_version)
+
+
 # The following code was copied from https://github.com/uranusjr/pipx-standalone
 # which uses the same technique to build a completely standalone pipx
 # distribution.
@@ -30,8 +74,14 @@ def has_venv() -> bool:
 def find_py_launcher_python(python_version: Optional[str] = None) -> Optional[str]:
     py = shutil.which("py")
     if py and python_version:
+        python_semver = python_version
+        if python_version.startswith("python"):
+            logging.warn(
+                "Removing `python` from the start of the version, as pylauncher just expects the semantic version"
+            )
+            python_semver = python_semver.lstrip("python")
         py = subprocess.run(
-            [py, f"-{python_version}", "-c", "import sys; print(sys.executable)"],
+            [py, f"-{python_semver}", "-c", "import sys; print(sys.executable)"],
             capture_output=True,
             text=True,
             check=True,
diff --git a/src/pipx/main.py b/src/pipx/main.py
index c7ada17..aa7ff86 100644
--- a/src/pipx/main.py
+++ b/src/pipx/main.py
@@ -23,9 +23,9 @@ import pipx.constants
 from pipx import commands, constants
 from pipx.animate import hide_cursor, show_cursor
 from pipx.colors import bold, green
-from pipx.constants import MINIMUM_PYTHON_VERSION, WINDOWS, ExitCode
+from pipx.constants import EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND, MINIMUM_PYTHON_VERSION, WINDOWS, ExitCode
 from pipx.emojis import hazard
-from pipx.interpreter import DEFAULT_PYTHON, find_py_launcher_python
+from pipx.interpreter import DEFAULT_PYTHON, InterpreterResolutionError, find_python_interpreter
 from pipx.util import PipxError, mkdir, pipx_wrap, rmdir
 from pipx.venv import VenvContainer
 from pipx.version import __version__
@@ -187,11 +187,18 @@ def run_pipx_command(args: argparse.Namespace) -> ExitCode:  # noqa: C901
     if "skip" in args:
         skip_list = [canonicalize_name(x) for x in args.skip]
 
-    if "python" in args:
-        if args.python is not None and not Path(args.python).is_file():
-            py_launcher_python = find_py_launcher_python(args.python)
-            if py_launcher_python:
-                args.python = py_launcher_python
+    if "python" in args and args.python is not None:
+        try:
+            interpreter = find_python_interpreter(args.python)
+            args.python = interpreter
+        except InterpreterResolutionError as e:
+            print(
+                pipx_wrap(
+                    f"{hazard} {e}",
+                    subsequent_indent=" " * 4,
+                )
+            )
+            return EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND
 
     if args.command == "run":
         commands.run(


Test patch:
diff --git a/tests/test_install.py b/tests/test_install.py
index a1a43f5..b814b73 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -337,9 +337,3 @@ def test_passed_python_and_force_flag_warning(pipx_temp_env, capsys):
     assert not run_pipx_cli(["install", "pycowsay", "--force"])
     captured = capsys.readouterr()
     assert "--python is ignored when --force is passed." not in captured.out
-
-
-def test_passed_python_not_executable(pipx_temp_env, capsys):
-    assert run_pipx_cli(["install", "--python", "py_not_real", "pycowsay"])
-    captured = capsys.readouterr()
-    assert "No executable for the provided Python version" in captured.out
diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py
index bbd013a..fb32a77 100644
--- a/tests/test_interpreter.py
+++ b/tests/test_interpreter.py
@@ -7,9 +7,10 @@ import pytest  # type: ignore
 
 import pipx.interpreter
 from pipx.interpreter import (
+    InterpreterResolutionError,
     _find_default_windows_python,
     _get_absolute_python_interpreter,
-    find_py_launcher_python,
+    find_python_interpreter,
 )
 from pipx.util import PipxError
 
@@ -24,12 +25,43 @@ def test_windows_python_with_version(monkeypatch, venv):
     minor = sys.version_info.minor
     monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: venv)
     monkeypatch.setattr(shutil, "which", which)
-    python_path = find_py_launcher_python(f"{major}.{minor}")
+    python_path = find_python_interpreter(f"{major}.{minor}")
     assert python_path is not None
     assert f"{major}.{minor}" in python_path or f"{major}{minor}" in python_path
     assert python_path.endswith("python.exe")
 
 
+@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Looks for Python.exe")
+@pytest.mark.parametrize("venv", [True, False])
+def test_windows_python_with_python_and_version(monkeypatch, venv):
+    def which(name):
+        return "py"
+
+    major = sys.version_info.major
+    minor = sys.version_info.minor
+    monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: venv)
+    monkeypatch.setattr(shutil, "which", which)
+    python_path = find_python_interpreter(f"python{major}.{minor}")
+    assert python_path is not None
+    assert f"{major}.{minor}" in python_path or f"{major}{minor}" in python_path
+    assert python_path.endswith("python.exe")
+
+
+@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Looks for Python.exe")
+@pytest.mark.parametrize("venv", [True, False])
+def test_windows_python_with_python_and_unavailable_version(monkeypatch, venv):
+    def which(name):
+        return "py"
+
+    major = sys.version_info.major + 99
+    minor = sys.version_info.minor
+    monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: venv)
+    monkeypatch.setattr(shutil, "which", which)
+    with pytest.raises(InterpreterResolutionError) as e:
+        find_python_interpreter(f"python{major}.{minor}")
+        assert "py --list" in str(e)
+
+
 def test_windows_python_no_version_with_venv(monkeypatch):
     monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: True)
     assert _find_default_windows_python() == sys.executable
@@ -108,3 +140,30 @@ def test_bad_env_python(monkeypatch):
 def test_good_env_python(monkeypatch, capsys):
     good_exec = _get_absolute_python_interpreter(sys.executable)
     assert good_exec == sys.executable
+
+
+def test_find_python_interpreter_by_path(monkeypatch):
+    interpreter_path = sys.executable
+    assert interpreter_path == find_python_interpreter(interpreter_path)
+
+
+def test_find_python_interpreter_by_version(monkeypatch):
+    major = sys.version_info.major
+    minor = sys.version_info.minor
+    python_path = find_python_interpreter(f"python{major}.{minor}")
+    assert python_path == f"python{major}.{minor}" or f"Python\\{major}.{minor}" in python_path
+
+
+def test_find_python_interpreter_by_wrong_path_raises(monkeypatch):
+    interpreter_path = sys.executable + "99"
+    with pytest.raises(InterpreterResolutionError) as e:
+        find_python_interpreter(interpreter_path)
+        assert "like a path" in str(e)
+
+
+def test_find_python_interpreter_missing_on_path_raises(monkeypatch):
+    interpreter = "1.1"
+    with pytest.raises(InterpreterResolutionError) as e:
+        find_python_interpreter(interpreter)
+        assert "Python Launcher" in str(e)
+        assert "on your PATH" in str(e)