Repository: pypa/pipx (python)

Issue:
ENH: Better handling of pipx install error when requested python version not available
**How would this feature be useful?**
Following the example from [here](https://pypa.github.io/pipx/examples/]:
```bash
pipx install --python python3.7 pycowsay
```
I get a rather unwieldy exception that would be better wrapped into something more end user friendly:

```txt
1 ❯ pipx install --python python3.7 pycowsay
Traceback (most recent call last):
  File "/opt/homebrew/bin/pipx", line 8, in <module>
    sys.exit(cli())
             ^^^^^
  File "/opt/homebrew/Cellar/pipx/1.2.0/libexec/lib/python3.12/site-packages/pipx/main.py", line 863, in cli
    return run_pipx_command(parsed_pipx_args)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/pipx/1.2.0/libexec/lib/python3.12/site-packages/pipx/main.py", line 214, in run_pipx_command
    return commands.install(
           ^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/pipx/1.2.0/libexec/lib/python3.12/site-packages/pipx/commands/install.py", line 59, in install
    venv.create_venv(venv_args, pip_args)
  File "/opt/homebrew/Cellar/pipx/1.2.0/libexec/lib/python3.12/site-packages/pipx/venv.py", line 162, in create_venv
    venv_process = run_subprocess(cmd + venv_args + [str(self.root)])
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/pipx/1.2.0/libexec/lib/python3.12/site-packages/pipx/util.py", line 176, in run_subprocess
    completed_process = subprocess.run(
                        ^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.0/Frameworks/Python.framework/Versions/3.12/lib/python3.12/subprocess.py", line 548, in run
    with Popen(*popenargs, **kwargs) as process:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.0/Frameworks/Python.framework/Versions/3.12/lib/python3.12/subprocess.py", line 1026, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/opt/homebrew/Cellar/python@3.12/3.12.0/Frameworks/Python.framework/Versions/3.12/lib/python3.12/subprocess.py", line 1950, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
FileNotFoundError: [Errno 2] No such file or directory: 'python3.7'
```

**Describe the solution you'd like**
It appears that `FileNotFoundError` is what you get when you don't have a particular python version installed but ask for.

`pipx` should validate first whether a python version is installed and raise a more user friendly error rather than exposing internals like this (unless one asks for --verbose output).

**Related**
Potentially related issues/PRs I found when looking for duplicates:
- https://github.com/pypa/pipx/issues/613
- https://github.com/pypa/pipx/pull/625

Pull request:
Print more user friendly error when --python version not found
<!-- add an 'x' in the brackets below -->

- [x] I have added an entry to `docs/changelog.md`

## Summary of changes

Resolves #1089 

Capture FileNotFound error during the install command and exit with a meaningful error message if it indicates that the provided python version was not found.

I think it would actually be nice to do the handling in [venv.create_venv](https://github.com/pypa/pipx/blob/1a3e6adf651d57b4750d7e6cfd5dca90c5080a29/src/pipx/commands/install.py#L83C22-L83C22) which is where the error surfaces. This would mean that the handling is uniform for all places that use this functionality (commands/run, commands/common, commands/install), bit it would also introduce a return value to this function.

`def create_venv(...) -> None:` -> `def create_venv(...) -> EXIT_CODE_OK | EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND:`

If that's desired I'm happy to change the code accordingly.

## Test plan

<!-- provide evidence of testing, preferably with command(s) that can be copy+pasted by others -->

Tested by running `nox -s tests-3.12`

```
$ python3 src/pipx install --python python3.2 cowssay
> No executable for the provided Python version 'python3.2' found.
> Please make sure the executable name is on your PATH/the path to the executable is correct.
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82c84c4..21a8ff2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 ## 1.4.1
 
 - Set default logging level to WARNING, so debug log messages won't be shown without passing additional flags such as `--verbose`
+- Raise more user friendly error when provided `--python` version is not found.
 
 ## 1.4.0
 
diff --git a/src/pipx/commands/install.py b/src/pipx/commands/install.py
index 8e84bd3..6b34f53 100644
--- a/src/pipx/commands/install.py
+++ b/src/pipx/commands/install.py
@@ -1,9 +1,14 @@
 from pathlib import Path
 from typing import List, Optional
 
-from pipx import constants
+from pipx import constants, emojis
 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, ExitCode
+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
 from pipx.util import pipx_wrap
 from pipx.venv import Venv, VenvContainer
@@ -102,6 +107,23 @@ 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/constants.py b/src/pipx/constants.py
index dbe3a70..8aba427 100644
--- a/src/pipx/constants.py
+++ b/src/pipx/constants.py
@@ -46,6 +46,7 @@ EXIT_CODE_UNINSTALL_VENV_NONEXISTENT = ExitCode(1)
 EXIT_CODE_UNINSTALL_ERROR = ExitCode(1)
 EXIT_CODE_REINSTALL_VENV_NONEXISTENT = ExitCode(1)
 EXIT_CODE_REINSTALL_INVALID_PYTHON = ExitCode(1)
+EXIT_CODE_SPECIFIED_PYTHON_EXECUTABLE_NOT_FOUND = ExitCode(1)
 
 pipx_log_file: Optional[Path] = None
 


Test patch:
diff --git a/tests/test_install.py b/tests/test_install.py
index b814b73..a1a43f5 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -337,3 +337,9 @@ 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