From a37e58a29ef3e1d4b04b0ac155a5f49b9783e6e4 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:42:08 +0800 Subject: [PATCH] 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 ... uv pip uninstall --python -y ... If neither pip nor uv is available, raise CliAppError. Fixes #4158 --- nanobot/apps/cli/service.py | 32 ++++++++++++++--- tests/cli_apps/test_service.py | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index f22e629ff..b53957f16 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -699,15 +699,31 @@ class CliAppManager: return None 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]: install_cmd = str(app.get("install_cmd") or "") if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd): raise CliAppError("unsupported pip install command") tokens = shlex.split(install_cmd) 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: - prefix.extend(["--upgrade", "--force-reinstall"]) + if pip_available: + prefix.extend(["--upgrade", "--force-reinstall"]) + else: + prefix.append("--upgrade") return prefix + args def _pip_uninstall_argv( @@ -715,18 +731,24 @@ class CliAppManager: app: dict[str, Any], installed_entry: dict[str, Any] | None = None, ) -> 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() if distribution: - return [sys.executable, "-m", "pip", "uninstall", "-y", distribution] + return [*prefix, distribution] uninstall_cmd = str(app.get("uninstall_cmd") or "") packages = _pip_uninstall_args_from_command(uninstall_cmd) 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) if not package: 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']))}" - return [sys.executable, "-m", "pip", "uninstall", "-y", package] + return [*prefix, package] def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]: npm = shutil.which("npm") diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 379389c47..6d07ecbf9 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -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"): 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", + ]