Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Bugfix] Support plugin installation with unexpected `pip` path and/or uv. (by @regisb)
22 changes: 22 additions & 0 deletions tests/commands/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,25 @@ def test_plugins_name_auto_complete(self, _iter_info: Mock) -> None:
self.assertEqual(
["all", "alba"], plugins_commands.PluginName(allow_all=True).get_names("al")
)

def test_package_install_command(self) -> None:
# python -m pip
with patch.object(plugins_commands.sys, "executable", "/my/python"):
with patch.dict("sys.modules", pip=Mock(main=lambda: None)):
command = plugins_commands.get_package_install_command()
self.assertEqual(["/my/python", "-m", "pip", "install"], command)

# python -m uv
with patch.dict("sys.modules", pip=None, uv=Mock(find_uv_bin=lambda: None)):
command = plugins_commands.get_package_install_command()
self.assertEqual(["python", "-m", "uv", "pip", "install"], command)

# uv
with patch.dict("sys.modules", pip=None, uv=None):
with patch.object(
plugins_commands.shutil,
"which",
lambda name: "/my/uv" if name == "uv" else None,
):
command = plugins_commands.get_package_install_command()
self.assertEqual(["/my/uv", "pip", "install"], command)
48 changes: 47 additions & 1 deletion tutor/commands/plugins.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import os
import shutil
import sys
import tempfile
import typing as t

Expand Down Expand Up @@ -261,11 +263,55 @@ def find_and_install(names: list[str], pip_install_opts: t.List[str]) -> None:
tmp_reqs.write(requirements_txt)
tmp_reqs.flush()
fmt.echo_info(f"Installing pip requirements:\n{requirements_txt}")

utils.execute(
"pip", "install", *pip_install_opts, "--requirement", tmp_reqs.name
*get_package_install_command(),
*pip_install_opts,
"--requirement",
tmp_reqs.name,
)


def get_package_install_command() -> list[str]:
"""
Return the `pip install` or `uv install` part from the installation command.

We attempt to check whether the following commands are available, in this order:
1. python -m pip install
2. python -m uv install
3. uv install
"""
# pip
try:
import pip # pylint: disable=unused-import, import-outside-toplevel
except ImportError:
pass
else:
# Note that we call `python -m pip install...` instead of `pip install...`
# because it's easier to find the executable path this way. Especially if tutor
# was launched as: `./.venv/bin/tutor`.
if hasattr(pip, "main"):
return [sys.executable, "-m", "pip", "install"]

# uv: from python
try:
import uv # pylint: disable=unused-import, import-outside-toplevel
except ImportError:
pass
else:
if hasattr(uv, "find_uv_bin"):
return ["python", "-m", "uv", "pip", "install"]

# uv: from binary
if uv := shutil.which("uv"):
return [uv, "pip", "install"]

raise exceptions.TutorError(
"Could not find a Python package installer such as 'pip' or 'uv'. "
"See: https://pip.pypa.io/en/stable/installation/"
)


def install_single_file_plugin(location: str) -> None:
"""
Download or copy a single file to the plugins root.
Expand Down