diff --git a/nanobot/optional_features.py b/nanobot/optional_features.py index 6f427c100..c3dac6f89 100644 --- a/nanobot/optional_features.py +++ b/nanobot/optional_features.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import os +import shutil import subprocess import sys from dataclasses import dataclass @@ -179,13 +181,18 @@ def extra_installed(extra: str, deps: list[str] | None) -> bool: return all(requirement_installed(dep, extra) for dep in deps) -def run_install_command(argv: list[str]) -> subprocess.CompletedProcess[str]: +def run_install_command( + argv: list[str], + *, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: try: return subprocess.run( argv, capture_output=True, text=True, timeout=_INSTALL_TIMEOUT_SECONDS, + env=env, ) except subprocess.TimeoutExpired as exc: stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else exc.stdout @@ -234,6 +241,20 @@ def install_extra( failed_cmd = pip_cmd failed_proc = proc if missing_pip(proc): + if shutil.which("uv"): + uv_cmd = ["uv", "pip", "install", "--python", sys.executable, *install_args] + uv_env = os.environ.copy() + if index_url := os.environ.get("PIP_INDEX_URL", "").strip(): + uv_env["UV_INDEX_URL"] = index_url + logger.info("pip missing while installing '{}'; running {}", extra, command_text(uv_cmd)) + uv_proc = runner(uv_cmd, env=uv_env) + _log_completed_command(f"Optional feature '{extra}' uv install", uv_proc) + if uv_proc.returncode == 0: + importlib.invalidate_caches() + return InstallResult(True, label, pip_cmd) + output = (uv_proc.stderr or uv_proc.stdout or "").strip() + return InstallResult(False, label, pip_cmd, failed_cmd=uv_cmd, output=output) + ensure_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"] logger.info("pip missing while installing '{}'; running {}", extra, command_text(ensure_cmd)) ensure_proc = runner(ensure_cmd) diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index fea682081..8b85c50f1 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -2479,6 +2479,69 @@ def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypat assert "instances" not in saved +@pytest.mark.parametrize( + "index_url", + [ + "", + "https://mirror.example/simple", + ], +) +def test_enable_uses_uv_when_tool_environment_has_no_pip( + monkeypatch, + index_url, +): + from nanobot import optional_features + + calls: list[list[str]] = [] + call_envs: list[dict[str, str] | None] = [] + + def _run( + argv: list[str], + *, + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + calls.append(argv) + call_envs.append(env) + if len(calls) == 1: + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip") + if argv[0] == "uv": + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + return subprocess.CompletedProcess( + argv, + 1, + stdout="", + stderr="No module named ensurepip", + ) + + monkeypatch.setattr("shutil.which", lambda name: "uv" if name == "uv" else None) + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") + monkeypatch.delenv("UV_INDEX_URL", raising=False) + if index_url: + monkeypatch.setenv("PIP_INDEX_URL", index_url) + else: + monkeypatch.delenv("PIP_INDEX_URL", raising=False) + + assert optional_features.install_extra("feishu", ["lark-oapi>=1.5.0"], runner=_run).ok is True + assert calls == [ + [sys.executable, "-m", "pip", "install", "lark-oapi>=1.5.0"], + [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "lark-oapi>=1.5.0", + ], + ] + assert call_envs[0] is None + assert call_envs[1] is not None + assert call_envs[1]["HTTPS_PROXY"] == "http://proxy.example:8080" + if index_url: + assert call_envs[1]["UV_INDEX_URL"] == index_url + else: + assert "UV_INDEX_URL" not in call_envs[1] + + def test_enable_bootstraps_pip_with_ensurepip(monkeypatch): from nanobot import optional_features @@ -2490,6 +2553,8 @@ def test_enable_bootstraps_pip_with_ensurepip(monkeypatch): return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip") return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + monkeypatch.setattr("shutil.which", lambda _name: None) + assert optional_features.install_extra("bedrock", None, runner=_run).ok is True assert calls == [ [sys.executable, "-m", "pip", "install", "nanobot-ai[bedrock]"],