Repository: pypa/pipx (python)

Issue:
Windows: Use the Python Launcher to Select Python Version
In macOS and Linux, pipx allows you to set the Python version using the `--python` option. For example, `pipx install --python python3.11 black`. However, in Windows, all Python executables are named `python.exe`. So the only solution I have found to tell pipx the interpreter is by giving the full path like:

```
pipx install --python 'C:\\Users\\dan\\AppData\\Local\\Programs\\Python\\Python311\\python.exe' black
```

The standard way to select a Python version in Windows is with the Python Launcher. For example, `py -3.11`, will launch Python 3.11. It is possible to use the launcher with pipx with `pipx install --python py black`. However, I don't see a way to tell `py` which version of Python to use.

**How would this feature be useful?**
In Windows on GitHub Actions, pipx is installed by default with Python 3.9. I would like to run the setup-python action and then tell pipx to use this later Python version.

**Describe the solution you'd like**
`pipx install --python 3.11 black` would use `py -3.11` on Windows and `python3.11` in Linux/macOS. An alternative might be `--python py311` which is the format tox uses.

**Describe alternatives you've considered**
Reinstall pipx for each Python version, or use the full path to the Python executable.

I would be glad to help implement this if there is agreement that this would be a nice enhancement.

Pull request description:
Use py launcher to select Python interpreter
* [X] I have added an entry to `docs/changelog.md`

## Summary of changes

This PR closes #940 by adding support to use the py launcher to select the Python interpreter when the `--python` option is provided.

The feature is used like `pipx install --python 3.10 cowsay` to install using the Python 3.10 interpreter. The only way to do this right now is by passing in the full path to the interpreter.

I also did some refactoring in nearby code to leave the codebase in better shape than I found it.

## Test plan

Tested by running
```
> pipx install --python 3.10 cowsay`
> pipx reinstall --python 3.11 cowsay`
> pipx uninstall cowsay`
> pipx run --python 3.9 cowsay hi`
```
`pipx install --python 3.100 cowsay` raises a FileNotFound error, which is similar to the behavior in Linux if you run `pipx install --python python3.100 cowsay`.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e61a4c8..7280022 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## dev
 
+- Use the py launcher, if available, to select Python version with the `--python` option
 - Support including requirements in scripts run using `pipx run` (#916)
 - Pass `pip_args` to `shared_libs.upgrade()`
 - Fallback to user's log path if the default log path (`$PIPX_HOME/logs`) is not writable to aid with pipx being used for multi-user (e.g. system-wide) installs of applications
diff --git a/docs/examples.md b/docs/examples.md
index cae08fb..61bed69 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -2,8 +2,8 @@
 
 ```
 pipx install pycowsay
-pipx install --python python3.7 pycowsay
-pipx install --python python3.8 pycowsay
+pipx install --python python3.10 pycowsay
+pipx install --python 3.11 pycowsay
 pipx install git+https://github.com/psf/black
 pipx install git+https://github.com/psf/black.git@branch-name
 pipx install git+https://github.com/psf/black.git@git-hash
diff --git a/src/pipx/commands/run.py b/src/pipx/commands/run.py
index ef7db41..06b6e39 100644
--- a/src/pipx/commands/run.py
+++ b/src/pipx/commands/run.py
@@ -74,7 +74,7 @@ def run_script(
 ) -> NoReturn:
     requirements = _get_requirements_from_script(content)
     if requirements is None:
-        exec_app([str(python), "-c", content, *app_args])
+        exec_app([python, "-c", content, *app_args])
     else:
         # Note that the environment name is based on the identified
         # requirements, and *not* on the script name. This is deliberate, as
@@ -106,7 +106,6 @@ def run_package(
     verbose: bool,
     use_cache: bool,
 ) -> NoReturn:
-
     if which(app):
         logger.warning(
             pipx_wrap(
@@ -180,7 +179,6 @@ def run(
     package
     """
 
-    package_or_url = spec if spec is not None else app
     # For any package, we need to just use the name
     try:
         package_name = Requirement(app).name
@@ -189,14 +187,11 @@ def run(
         # we can't parse this as a package
         package_name = app
 
-    if spec is not None:
-        content = None
-    else:
-        content = maybe_script_content(app, is_path)
-
+    content = None if spec is not None else maybe_script_content(app, is_path)
     if content is not None:
         run_script(content, app_args, python, pip_args, venv_args, verbose, use_cache)
     else:
+        package_or_url = spec if spec is not None else app
         run_package(
             package_name,
             package_or_url,
@@ -287,7 +282,7 @@ def _get_temporary_venv_path(
     m.update(python.encode())
     m.update("".join(pip_args).encode())
     m.update("".join(venv_args).encode())
-    venv_folder_name = m.hexdigest()[0:15]  # 15 chosen arbitrarily
+    venv_folder_name = m.hexdigest()[:15]  # 15 chosen arbitrarily
     return Path(constants.PIPX_VENV_CACHEDIR) / venv_folder_name
 
 
@@ -321,11 +316,10 @@ def _http_get_request(url: str) -> str:
         return res.read().decode(charset)
     except Exception as e:
         logger.debug("Uncaught Exception:", exc_info=True)
-        raise PipxError(str(e))
+        raise PipxError(str(e)) from e
 
 
 def _get_requirements_from_script(content: str) -> Optional[List[str]]:
-
     # An iterator over the lines in the script. We will
     # read through this in sections, so it needs to be an
     # iterator, not just a list.
@@ -356,7 +350,7 @@ def _get_requirements_from_script(content: str) -> Optional[List[str]]:
         try:
             req = Requirement(line_content)
         except InvalidRequirement as e:
-            raise PipxError(f"Invalid requirement {line_content}: {str(e)}")
+            raise PipxError(f"Invalid requirement {line_content}: {str(e)}") from e
 
         # Use the normalised form of the requirement,
         # not the original line.
diff --git a/src/pipx/constants.py b/src/pipx/constants.py
index bdc87a7..f2efb6c 100644
--- a/src/pipx/constants.py
+++ b/src/pipx/constants.py
@@ -19,6 +19,7 @@ PIPX_SHARED_PTH = "pipx_shared.pth"
 LOCAL_BIN_DIR = Path(os.environ.get("PIPX_BIN_DIR", DEFAULT_PIPX_BIN_DIR)).resolve()
 PIPX_VENV_CACHEDIR = PIPX_HOME / ".cache"
 TEMP_VENV_EXPIRATION_THRESHOLD_DAYS = 14
+MINIMUM_PYTHON_VERSION = "3.8"
 
 ExitCode = NewType("ExitCode", int)
 # pipx shell exit codes
diff --git a/src/pipx/interpreter.py b/src/pipx/interpreter.py
index 97f6d40..6ec093c 100644
--- a/src/pipx/interpreter.py
+++ b/src/pipx/interpreter.py
@@ -2,6 +2,7 @@ import os
 import shutil
 import subprocess
 import sys
+from typing import Optional
 
 from pipx.constants import WINDOWS
 from pipx.util import PipxError
@@ -26,14 +27,22 @@ def has_venv() -> bool:
 # so we try to locate the system Python and use that instead.
 
 
+def find_py_launcher_python(python_version: Optional[str] = None) -> Optional[str]:
+    py = shutil.which("py")
+    if py and python_version:
+        py = subprocess.run(
+            [py, f"-{python_version}", "-c", "import sys; print(sys.executable)"],
+            capture_output=True,
+            text=True,
+        ).stdout.strip()
+    return py
+
+
 def _find_default_windows_python() -> str:
     if has_venv():
         return sys.executable
+    python = find_py_launcher_python() or shutil.which("python")
 
-    py = shutil.which("py")
-    if py:
-        return py
-    python = shutil.which("python")
     if python is None:
         raise PipxError("No suitable Python found")
 
diff --git a/src/pipx/main.py b/src/pipx/main.py
index 1c44dd0..5629e3a 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 WINDOWS, ExitCode
+from pipx.constants import MINIMUM_PYTHON_VERSION, WINDOWS, ExitCode
 from pipx.emojis import hazard
-from pipx.interpreter import DEFAULT_PYTHON
+from pipx.interpreter import DEFAULT_PYTHON, find_py_launcher_python
 from pipx.util import PipxError, mkdir, pipx_wrap, rmdir
 from pipx.venv import VenvContainer
 from pipx.version import __version__
@@ -181,6 +181,10 @@ def run_pipx_command(args: argparse.Namespace) -> ExitCode:  # noqa: C901
         logger.info(f"Virtual Environment location is {venv_dir}")
     if "skip" in args:
         skip_list = [canonicalize_name(x) for x in args.skip]
+    if "python" in args 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 args.command == "run":
         commands.run(
@@ -341,8 +345,9 @@ def _add_install(subparsers: argparse._SubParsersAction) -> None:
         "--python",
         default=DEFAULT_PYTHON,
         help=(
-            "The Python executable used to create the Virtual Environment and run the "
-            "associated app/apps. Must be v3.6+."
+            "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."
         ),
     )
     add_pip_venv_args(p)
@@ -484,8 +489,9 @@ def _add_reinstall(subparsers, venv_completer: VenvCompleter) -> None:
         "--python",
         default=DEFAULT_PYTHON,
         help=(
-            "The Python executable used to recreate the Virtual Environment "
-            "and run the associated app/apps. Must be v3.6+."
+            "Python to reinstall 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."
         ),
     )
     p.add_argument("--verbose", action="store_true")
@@ -512,8 +518,9 @@ def _add_reinstall_all(subparsers: argparse._SubParsersAction) -> None:
         "--python",
         default=DEFAULT_PYTHON,
         help=(
-            "The Python executable used to recreate the Virtual Environment "
-            "and run the associated app/apps. Must be v3.6+."
+            "Python to reinstall 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."
         ),
     )
     p.add_argument("--skip", nargs="+", default=[], help="skip these packages")
@@ -588,7 +595,11 @@ def _add_run(subparsers: argparse._SubParsersAction) -> None:
     p.add_argument(
         "--python",
         default=DEFAULT_PYTHON,
-        help="The Python version to run package's CLI app with. Must be v3.6+.",
+        help=(
+            "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. "
+            f"Requires Python {MINIMUM_PYTHON_VERSION} or above."
+        ),
     )
     add_pip_venv_args(p)
     p.set_defaults(subparser=p)


Test patch:
diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml
index 35a02f9..17a5bae 100644
--- a/.github/workflows/publish-testpypi.yml
+++ b/.github/workflows/publish-testpypi.yml
@@ -32,7 +32,7 @@ jobs:
       run: |
         nox --error-on-missing-interpreters --non-interactive --session build
     - name: Publish to Test PyPi
-      uses: pypa/gh-action-pypi-publish@v1.8.3
+      uses: pypa/gh-action-pypi-publish@v1.8.7
       with:
         user: __token__
         password: ${{ secrets.test_pypi_password }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 82bf875..c430c0e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -110,7 +110,7 @@ jobs:
       run: |
         nox --error-on-missing-interpreters --non-interactive --session build
     - name: Publish to PyPi
-      uses: pypa/gh-action-pypi-publish@v1.8.3
+      uses: pypa/gh-action-pypi-publish@v1.8.7
       with:
         user: __token__
         password: ${{ secrets.pypi_password }}
diff --git a/tests/test_install.py b/tests/test_install.py
index a135b86..6e8a356 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -144,7 +144,7 @@ def test_extra(pipx_temp_env, capsys):
 
 def test_install_local_extra(pipx_temp_env, capsys):
     assert not run_pipx_cli(
-        ["install", TEST_DATA_PATH + "/local_extras[cow]", "--include-deps"]
+        ["install", f"{TEST_DATA_PATH}/local_extras[cow]", "--include-deps"]
     )
     captured = capsys.readouterr()
     assert f"- {app_name('pycowsay')}\n" in captured.out
@@ -250,7 +250,7 @@ def test_install_pip_failure(pipx_temp_env, capsys):
         r"Full pip output in file:\s+(\S.+)$", captured.err, re.MULTILINE
     )
     assert pip_log_file_match
-    assert Path(pip_log_file_match.group(1)).exists()
+    assert Path(pip_log_file_match[1]).exists()
 
     assert re.search(r"pip (failed|seemed to fail) to build package", captured.err)
 
diff --git a/tests/test_interpreter.py b/tests/test_interpreter.py
index 167c6c3..1c2f3c9 100644
--- a/tests/test_interpreter.py
+++ b/tests/test_interpreter.py
@@ -8,26 +8,41 @@ import pipx.interpreter
 from pipx.interpreter import (
     _find_default_windows_python,
     _get_absolute_python_interpreter,
+    find_py_launcher_python,
 )
 from pipx.util import PipxError
 
 
-def test_windows_python_venv_present(monkeypatch):
+@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"
+
+    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_py_launcher_python(f"{major}.{minor}")
+    assert f"{major}.{minor}" in python_path or f"{major}{minor}" in python_path
+    assert python_path.endswith("python.exe")
+
+
+def test_windows_python_no_version_with_venv(monkeypatch):
     monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: True)
     assert _find_default_windows_python() == sys.executable
 
 
-def test_windows_python_no_venv_py_present(monkeypatch):
+def test_windows_python_no_version_no_venv_with_py(monkeypatch):
     def which(name):
-        if name == "py":
-            return "py"
+        return "py"
 
     monkeypatch.setattr(pipx.interpreter, "has_venv", lambda: False)
     monkeypatch.setattr(shutil, "which", which)
     assert _find_default_windows_python() == "py"
 
 
-def test_windows_python_no_venv_python_present(monkeypatch):
+def test_windows_python_no_version_no_venv_python_present(monkeypatch):
     def which(name):
         if name == "python":
             return "python"
@@ -38,7 +53,7 @@ def test_windows_python_no_venv_python_present(monkeypatch):
     assert _find_default_windows_python() == "python"
 
 
-def test_windows_python_no_venv_no_python(monkeypatch):
+def test_windows_python_no_version_no_venv_no_python(monkeypatch):
     def which(name):
         return None
 
diff --git a/tests/test_run.py b/tests/test_run.py
index 6d5e4e1..5c21262 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -316,3 +316,23 @@ def test_run_script_by_relative_name(caplog, pipx_temp_env, monkeypatch, tmp_pat
         m.chdir(tmp_path)
         run_pipx_cli_exit(["run", "test.py"])
     assert out.read_text() == test_str
+
+
+@pytest.mark.skipif(
+    not sys.platform.startswith("win"), reason="uses windows version format"
+)
+@mock.patch("os.execvpe", new=execvpe_mock)
+def test_run_with_windows_python_version(caplog, pipx_temp_env, tmp_path):
+    script = tmp_path / "test.py"
+    out = tmp_path / "output.txt"
+    script.write_text(
+        textwrap.dedent(
+            f"""
+                import sys
+                from pathlib import Path
+                Path({repr(str(out))}).write_text(sys.version)
+            """
+        ).strip()
+    )
+    run_pipx_cli_exit(["run", script.as_uri(), "--python", "3.11"])
+    assert "3.11" in out.read_text()