mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
fix(cli): fall back to uv pip when pip is unavailable
When nanobot is installed via uv tool install, sys.executable points to
a Python that does not have pip available as a module. _pip_install_argv
and _pip_uninstall_argv always used [sys.executable, -m, pip, ...]
which fails in that environment.
Add _pip_available() helper that checks importlib.util.find_spec('pip').
When pip is not available and uv is on PATH, fall back to:
uv pip install --python <sys.executable> ...
uv pip uninstall --python <sys.executable> -y ...
If neither pip nor uv is available, raise CliAppError.
Fixes #4158
This commit is contained in:
parent
24e56fcf07
commit
a37e58a29e
@ -699,15 +699,31 @@ class CliAppManager:
|
|||||||
return None
|
return None
|
||||||
return args[0]
|
return args[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pip_available() -> bool:
|
||||||
|
"""Return True if pip is importable for the current interpreter."""
|
||||||
|
from importlib.util import find_spec
|
||||||
|
|
||||||
|
return find_spec("pip") is not None
|
||||||
|
|
||||||
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
|
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
|
||||||
install_cmd = str(app.get("install_cmd") or "")
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
|
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
|
||||||
raise CliAppError("unsupported pip install command")
|
raise CliAppError("unsupported pip install command")
|
||||||
tokens = shlex.split(install_cmd)
|
tokens = shlex.split(install_cmd)
|
||||||
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
|
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
|
||||||
prefix = [sys.executable, "-m", "pip", "install"]
|
pip_available = self._pip_available()
|
||||||
|
if pip_available:
|
||||||
|
prefix = [sys.executable, "-m", "pip", "install"]
|
||||||
|
elif shutil.which("uv"):
|
||||||
|
prefix = ["uv", "pip", "install", "--python", sys.executable]
|
||||||
|
else:
|
||||||
|
raise CliAppError("pip is not available and uv is not installed")
|
||||||
if update:
|
if update:
|
||||||
prefix.extend(["--upgrade", "--force-reinstall"])
|
if pip_available:
|
||||||
|
prefix.extend(["--upgrade", "--force-reinstall"])
|
||||||
|
else:
|
||||||
|
prefix.append("--upgrade")
|
||||||
return prefix + args
|
return prefix + args
|
||||||
|
|
||||||
def _pip_uninstall_argv(
|
def _pip_uninstall_argv(
|
||||||
@ -715,18 +731,24 @@ class CliAppManager:
|
|||||||
app: dict[str, Any],
|
app: dict[str, Any],
|
||||||
installed_entry: dict[str, Any] | None = None,
|
installed_entry: dict[str, Any] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
if self._pip_available():
|
||||||
|
prefix = [sys.executable, "-m", "pip", "uninstall", "-y"]
|
||||||
|
elif shutil.which("uv"):
|
||||||
|
prefix = ["uv", "pip", "uninstall", "--python", sys.executable, "-y"]
|
||||||
|
else:
|
||||||
|
raise CliAppError("pip is not available and uv is not installed")
|
||||||
distribution = str((installed_entry or {}).get("pip_distribution") or "").strip()
|
distribution = str((installed_entry or {}).get("pip_distribution") or "").strip()
|
||||||
if distribution:
|
if distribution:
|
||||||
return [sys.executable, "-m", "pip", "uninstall", "-y", distribution]
|
return [*prefix, distribution]
|
||||||
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
uninstall_cmd = str(app.get("uninstall_cmd") or "")
|
||||||
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
packages = _pip_uninstall_args_from_command(uninstall_cmd)
|
||||||
if packages:
|
if packages:
|
||||||
return [sys.executable, "-m", "pip", "uninstall", "-y", *packages]
|
return [*prefix, *packages]
|
||||||
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
|
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
|
||||||
if not package:
|
if not package:
|
||||||
entry_point = str(app.get("entry_point") or "").strip()
|
entry_point = str(app.get("entry_point") or "").strip()
|
||||||
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
|
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
|
||||||
return [sys.executable, "-m", "pip", "uninstall", "-y", package]
|
return [*prefix, package]
|
||||||
|
|
||||||
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
|
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
|
||||||
npm = shutil.which("npm")
|
npm = shutil.which("npm")
|
||||||
|
|||||||
@ -777,3 +777,68 @@ def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
with pytest.raises(CliAppError, match="outside the configured workspace"):
|
with pytest.raises(CliAppError, match="outside the configured workspace"):
|
||||||
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
|
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_uses_uv_pip_when_pip_unavailable(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||||
|
calls.append(argv)
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
||||||
|
|
||||||
|
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.apps.cli.service.shutil.which",
|
||||||
|
lambda command: "/usr/bin/uv" if command == "uv" else None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
||||||
|
monkeypatch.setattr(manager, "_fetch_skill_content", lambda app: None)
|
||||||
|
|
||||||
|
manager.install("gimp")
|
||||||
|
|
||||||
|
assert calls[0][:6] == [
|
||||||
|
"uv",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--python",
|
||||||
|
sys.executable,
|
||||||
|
"cli-anything-gimp",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_uses_uv_pip_when_pip_unavailable(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = _manager(tmp_path)
|
||||||
|
_seed_catalog(manager)
|
||||||
|
manager._save_installed({"suno": {"entry_point": "suno"}})
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
||||||
|
calls.append(argv)
|
||||||
|
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
|
||||||
|
|
||||||
|
monkeypatch.setattr(CliAppManager, "_pip_available", staticmethod(lambda: False))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.apps.cli.service.shutil.which",
|
||||||
|
lambda command: "/usr/bin/uv" if command == "uv" else None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(manager, "_run_argv", fake_run)
|
||||||
|
|
||||||
|
manager.uninstall("suno")
|
||||||
|
|
||||||
|
assert calls[0] == [
|
||||||
|
"uv",
|
||||||
|
"pip",
|
||||||
|
"uninstall",
|
||||||
|
"--python",
|
||||||
|
sys.executable,
|
||||||
|
"-y",
|
||||||
|
"suno-cli",
|
||||||
|
]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user