mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
Copilot review on PR #29703 flagged two issues with the `uv tool list` fallback in `is_uv_tool_install`: 1. False positive: `uv tool list` returns the *machine*'s installed tools, not the active install. A regular pip/venv Hermes on a host that also has `uv tool install hermes-agent` available would be misclassified as a uv-tool install, and `hermes update` would upgrade the wrong copy. 2. Overhead: the subprocess call (up to a 15s timeout) was triggered even from `recommended_update_command_for_method`, which just computes a display string. Restrict detection to properties of the running interpreter (`sys.prefix` and `sys.executable` — both can carry the uv-tool layout marker depending on entry point). Drop the `uv tool list` fallback and the `uv_path` parameter entirely. `_cmd_update_pip` now also surfaces a clear hint when the runtime looks like a uv-tool install but `uv` is missing from PATH, instead of silently falling back to `python -m pip`.
217 lines
9.7 KiB
Python
217 lines
9.7 KiB
Python
"""Tests for uv-tool install detection in the update path (issue #29700).
|
|
|
|
``uv tool install hermes-agent`` lives outside any venv, so the previous
|
|
``uv pip install --upgrade`` update path failed with ``No virtual
|
|
environment found``. ``is_uv_tool_install`` should detect this layout and
|
|
both the user-facing recommended command and the actual
|
|
``_cmd_update_pip`` subprocess invocation should switch to
|
|
``uv tool upgrade hermes-agent``.
|
|
|
|
Detection is restricted to properties of the running interpreter
|
|
(``sys.prefix`` / ``sys.executable``) so a pip/venv install on a machine
|
|
that also has ``uv tool install hermes-agent`` does not get misclassified.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_uv_tool_install
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsUvToolInstall:
|
|
def test_returns_true_when_sys_prefix_matches_uv_tool_layout(self):
|
|
from hermes_cli import config
|
|
|
|
with patch.object(config.sys, "prefix", "/home/user/.local/share/uv/tools/hermes-agent"):
|
|
assert config.is_uv_tool_install() is True
|
|
|
|
def test_returns_true_when_sys_executable_matches_uv_tool_layout(self):
|
|
"""Some uv-tool layouts surface the marker on ``sys.executable`` (bin/python)."""
|
|
from hermes_cli import config
|
|
|
|
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
|
|
patch.object(
|
|
config.sys,
|
|
"executable",
|
|
"/home/user/.local/share/uv/tools/hermes-agent/bin/python",
|
|
):
|
|
assert config.is_uv_tool_install() is True
|
|
|
|
def test_returns_false_when_neither_prefix_nor_executable_matches(self):
|
|
from hermes_cli import config
|
|
|
|
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
|
|
patch.object(config.sys, "executable", "/usr/bin/python3"):
|
|
assert config.is_uv_tool_install() is False
|
|
|
|
def test_does_not_consult_uv_tool_list(self):
|
|
"""Detection must NOT shell out: ``uv tool list`` would false-positive
|
|
when the active install is pip/venv but the machine also has
|
|
``uv tool install hermes-agent`` somewhere on disk. Copilot review on
|
|
PR #29703 flagged this; the fix is to never call ``uv tool list``
|
|
from the detection path."""
|
|
from hermes_cli import config
|
|
|
|
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
|
|
patch.object(config.sys, "executable", "/usr/bin/python3"), \
|
|
patch("subprocess.run") as mock_run:
|
|
assert config.is_uv_tool_install() is False
|
|
mock_run.assert_not_called()
|
|
|
|
def test_case_insensitive_match(self):
|
|
"""Match must be case-insensitive — Windows paths preserve case
|
|
(e.g. ``...AppData\\Local\\UV\\Tools\\hermes-agent``) and a case-sensitive
|
|
check would miss them. We exercise the lower-cased compare path here
|
|
without monkey-patching ``os.sep``, which would break the whole suite."""
|
|
from hermes_cli import config
|
|
|
|
with patch.object(
|
|
config.sys, "prefix", "/HOME/USER/.local/share/UV/Tools/hermes-agent"
|
|
):
|
|
assert config.is_uv_tool_install() is True
|
|
|
|
def test_handles_empty_executable(self):
|
|
from hermes_cli import config
|
|
|
|
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
|
|
patch.object(config.sys, "executable", ""):
|
|
assert config.is_uv_tool_install() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# recommended_update_command_for_method
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRecommendedUpdateCommandForUvTool:
|
|
def test_uv_tool_install_recommends_uv_tool_upgrade(self):
|
|
from hermes_cli import config
|
|
|
|
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
|
patch.object(config, "is_uv_tool_install", return_value=True):
|
|
cmd = config.recommended_update_command_for_method("pip")
|
|
assert cmd == "uv tool upgrade hermes-agent"
|
|
|
|
def test_uv_tool_install_recommends_uv_tool_upgrade_even_without_uv_on_path(self):
|
|
"""Recommendation reflects the *install method*, not whether ``uv`` is
|
|
currently on PATH — the user needs to know the right command to run."""
|
|
from hermes_cli import config
|
|
|
|
with patch("shutil.which", return_value=None), \
|
|
patch.object(config, "is_uv_tool_install", return_value=True):
|
|
cmd = config.recommended_update_command_for_method("pip")
|
|
assert cmd == "uv tool upgrade hermes-agent"
|
|
|
|
def test_uv_pip_install_keeps_legacy_recommendation(self):
|
|
"""Existing behavior: uv is on PATH but Hermes is a regular pip install."""
|
|
from hermes_cli import config
|
|
|
|
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
|
patch.object(config, "is_uv_tool_install", return_value=False):
|
|
cmd = config.recommended_update_command_for_method("pip")
|
|
assert cmd == "uv pip install --upgrade hermes-agent"
|
|
|
|
def test_no_uv_falls_back_to_plain_pip(self):
|
|
from hermes_cli import config
|
|
|
|
with patch("shutil.which", return_value=None), \
|
|
patch.object(config, "is_uv_tool_install", return_value=False):
|
|
cmd = config.recommended_update_command_for_method("pip")
|
|
assert cmd == "pip install --upgrade hermes-agent"
|
|
|
|
def test_recommendation_does_not_spawn_subprocess(self):
|
|
"""Computing the recommendation string must be cheap — no ``uv tool list``
|
|
spawn. Copilot review on PR #29703 flagged the prior subprocess hop
|
|
as adding overhead and a multi-second timeout window for what is
|
|
purely a display string."""
|
|
from hermes_cli import config
|
|
|
|
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
|
|
patch.object(config.sys, "executable", "/usr/bin/python3"), \
|
|
patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
|
patch("subprocess.run") as mock_run:
|
|
cmd = config.recommended_update_command_for_method("pip")
|
|
mock_run.assert_not_called()
|
|
assert cmd == "uv pip install --upgrade hermes-agent"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cmd_update_pip subprocess command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCmdUpdatePipUsesUvTool:
|
|
@patch("subprocess.run")
|
|
def test_runs_uv_tool_upgrade_when_uv_tool_install(self, mock_run):
|
|
"""The actual subprocess invocation must switch to ``uv tool upgrade``."""
|
|
from hermes_cli.main import _cmd_update_pip
|
|
|
|
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
|
|
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
|
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
|
|
_cmd_update_pip(SimpleNamespace())
|
|
|
|
assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"]
|
|
|
|
@patch("subprocess.run")
|
|
def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run):
|
|
"""Existing behavior preserved when uv is present but Hermes isn't a tool install."""
|
|
from hermes_cli.main import _cmd_update_pip
|
|
|
|
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
|
|
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
|
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
|
_cmd_update_pip(SimpleNamespace())
|
|
|
|
assert mock_run.call_args[0][0] == [
|
|
"/usr/local/bin/uv",
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"hermes-agent",
|
|
]
|
|
|
|
@patch("subprocess.run")
|
|
def test_falls_back_to_pip_when_no_uv(self, mock_run):
|
|
from hermes_cli.main import _cmd_update_pip
|
|
|
|
mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="")
|
|
with patch("shutil.which", return_value=None), \
|
|
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
|
_cmd_update_pip(SimpleNamespace())
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"]
|
|
|
|
@patch("subprocess.run")
|
|
def test_exits_nonzero_on_subprocess_failure(self, mock_run):
|
|
from hermes_cli.main import _cmd_update_pip
|
|
|
|
mock_run.return_value = subprocess.CompletedProcess(["uv"], 1, stdout="", stderr="")
|
|
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
|
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
_cmd_update_pip(SimpleNamespace())
|
|
assert exc_info.value.code == 1
|
|
|
|
@patch("subprocess.run")
|
|
def test_uv_tool_install_without_uv_on_path_exits_with_hint(self, mock_run):
|
|
"""If the running interpreter looks like a uv-tool install but ``uv`` is
|
|
somehow missing from PATH, surface a clear hint instead of silently
|
|
falling back to ``python -m pip``, which would either fail (no venv)
|
|
or upgrade the wrong copy."""
|
|
from hermes_cli.main import _cmd_update_pip
|
|
|
|
with patch("shutil.which", return_value=None), \
|
|
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
_cmd_update_pip(SimpleNamespace())
|
|
assert exc_info.value.code == 1
|
|
mock_run.assert_not_called()
|