Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use py launcher to select Python interpreter #1002

Merged
merged 20 commits into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 6 additions & 12 deletions src/pipx/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,7 +106,6 @@ def run_package(
verbose: bool,
use_cache: bool,
) -> NoReturn:

if which(app):
logger.warning(
pipx_wrap(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/pipx/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions src/pipx/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import shutil
import subprocess
import sys
from typing import Optional

from pipx.constants import WINDOWS
from pipx.util import PipxError
Expand All @@ -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")

Expand Down
29 changes: 20 additions & 9 deletions src/pipx/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."
danyeaw marked this conversation as resolved.
Show resolved Hide resolved
f"Requires Python {MINIMUM_PYTHON_VERSION} or above."
),
)
add_pip_venv_args(p)
Expand Down Expand Up @@ -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), "
danyeaw marked this conversation as resolved.
Show resolved Hide resolved
"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")
Expand All @@ -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."
danyeaw marked this conversation as resolved.
Show resolved Hide resolved
f"Requires Python {MINIMUM_PYTHON_VERSION} or above."
),
)
p.add_argument("--skip", nargs="+", default=[], help="skip these packages")
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
27 changes: 21 additions & 6 deletions tests/test_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down
20 changes: 20 additions & 0 deletions tests/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()