Skip to content

Commit c737cd8

Browse files
committed
fix: support plugins install ... without pip, or pip outside path
This change addresses two issues: 1. When running tutor as `.venv/bin/tutor ...`, we might not have the right `pip` in the PATH. To resolve this, we install plugins with `python -m pip install ...`. 2. When pip is not available, but `uv` is, we should be using the latter, not the former. Close #1228.
1 parent 659e251 commit c737cd8

File tree

3 files changed

+70
-1
lines changed

3 files changed

+70
-1
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- [Bugfix] Support plugin installation with unexpected `pip` path and/or uv. (by @regisb)

tests/commands/test_plugins.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,25 @@ def test_plugins_name_auto_complete(self, _iter_info: Mock) -> None:
5555
self.assertEqual(
5656
["all", "alba"], plugins_commands.PluginName(allow_all=True).get_names("al")
5757
)
58+
59+
def test_package_install_command(self) -> None:
60+
# python -m pip
61+
with patch.object(plugins_commands.sys, "executable", "/my/python"):
62+
with patch.dict("sys.modules", pip=Mock(main=lambda: None)):
63+
command = plugins_commands.get_package_install_command()
64+
self.assertEqual(["/my/python", "-m", "pip", "install"], command)
65+
66+
# python -m uv
67+
with patch.dict("sys.modules", pip=None, uv=Mock(find_uv_bin=lambda: None)):
68+
command = plugins_commands.get_package_install_command()
69+
self.assertEqual(["python", "-m", "uv", "pip", "install"], command)
70+
71+
# uv
72+
with patch.dict("sys.modules", pip=None, uv=None):
73+
with patch.object(
74+
plugins_commands.shutil,
75+
"which",
76+
lambda name: "/my/uv" if name == "uv" else None,
77+
):
78+
command = plugins_commands.get_package_install_command()
79+
self.assertEqual(["/my/uv", "pip", "install"], command)

tutor/commands/plugins.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import os
4+
import shutil
5+
import sys
46
import tempfile
57
import typing as t
68

@@ -261,11 +263,55 @@ def find_and_install(names: list[str], pip_install_opts: t.List[str]) -> None:
261263
tmp_reqs.write(requirements_txt)
262264
tmp_reqs.flush()
263265
fmt.echo_info(f"Installing pip requirements:\n{requirements_txt}")
266+
264267
utils.execute(
265-
"pip", "install", *pip_install_opts, "--requirement", tmp_reqs.name
268+
*get_package_install_command(),
269+
*pip_install_opts,
270+
"--requirement",
271+
tmp_reqs.name,
266272
)
267273

268274

275+
def get_package_install_command() -> list[str]:
276+
"""
277+
Return the `pip install` or `uv install` part from the installation command.
278+
279+
We attempt to check whether the following commands are available, in this order:
280+
1. python -m pip install
281+
2. python -m uv install
282+
3. uv install
283+
"""
284+
# pip
285+
try:
286+
import pip # pylint: disable=unused-import, import-outside-toplevel
287+
except ImportError:
288+
pass
289+
else:
290+
# Note that we call `python -m pip install...` instead of `pip install...`
291+
# because it's easier to find the executable path this way. Especially if tutor
292+
# was launched as: `./.venv/bin/tutor`.
293+
if hasattr(pip, "main"):
294+
return [sys.executable, "-m", "pip", "install"]
295+
296+
# uv: from python
297+
try:
298+
import uv# pylint: disable=unused-import, import-outside-toplevel
299+
except ImportError:
300+
pass
301+
else:
302+
if hasattr(uv, "find_uv_bin"):
303+
return ["python", "-m", "uv", "pip", "install"]
304+
305+
# uv: from binary
306+
if uv := shutil.which("uv"):
307+
return [uv, "pip", "install"]
308+
309+
raise exceptions.TutorError(
310+
"Could not find a Python package installer such as 'pip' or 'uv'. "
311+
"See: https://pip.pypa.io/en/stable/installation/"
312+
)
313+
314+
269315
def install_single_file_plugin(location: str) -> None:
270316
"""
271317
Download or copy a single file to the plugins root.

0 commit comments

Comments
 (0)