diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d3ca4ce7..1441433270 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 cae08fbee6..61bed698e8 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 b4d1b90ebd..9cc1d8baf8 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 bdc87a7a43..f2efb6c8c4 100644 --- a/src/pipx/constants.py +++ b/src/pipx/constants.py @@ -19,6 +19,7 @@ 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 331caac3ee..cab29e8ee0 100644 --- a/src/pipx/interpreter.py +++ b/src/pipx/interpreter.py @@ -2,6 +2,7 @@ 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 59f6222288..988f2d324e 100644 --- a/src/pipx/main.py +++ b/src/pipx/main.py @@ -23,9 +23,9 @@ 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) diff --git a/tests/test_install.py b/tests/test_install.py index a135b86101..6e8a3566dd 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 167c6c3b06..1c2f3c92de 100644 --- a/tests/test_interpreter.py +++ b/tests/test_interpreter.py @@ -8,26 +8,41 @@ 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 which(name): 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 6d5e4e1471..5c2126289d 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()