hermes-agent/tests/hermes_cli/test_managed_uv.py
brooklyn! d880b5be09
fix(update/windows): don't return _UvResult on Windows (subprocess argv crash) (#39820)
PR #39780 made ensure_uv() return a _UvResult — a str subclass whose
__iter__ yields (path, fresh_bootstrap) so old `uv_bin, fresh = ensure_uv()`
call sites survive the update boundary. That trick is unsafe on Windows.

The dependency installer passes uv straight into the command list
(`[uv_bin, "pip", "install", ...]`). On Windows, subprocess serializes argv
via subprocess.list2cmdline, which iterates every entry *as a string*
(`for c in arg`). Because _UvResult overrides __iter__, that iteration yields
(path, fresh_bootstrap) instead of characters, injecting the bool into the
command line and crashing the first update with:

    TypeError: sequence item 1: expected str instance, bool found

This bites the common single-assignment caller (`uv_bin = ensure_uv()`) on
its first update after #39780: the freshly pulled _UvResult flows into the
old in-memory call site and into the argv. Reported in the field on a
~10-commits-behind Windows install.

A single return value cannot satisfy both legacy 2-target unpacking and
Windows char-iteration — both use the iterator protocol with contradictory
results. So gate the wrapper to POSIX: Windows returns a plain str/None
(the historical, subprocess-safe contract). POSIX keeps _UvResult and the
#39780 update-boundary fix.

Tests: list2cmdline canary proving _UvResult breaks Windows, plus Windows
returns-plain-str and POSIX dual-contract coverage.
2026-06-05 07:54:08 -05:00

255 lines
12 KiB
Python

"""Tests for hermes_cli.managed_uv — one path, no guessing."""
from __future__ import annotations
import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_executable(path: Path) -> None:
"""Create a minimal fake uv binary at *path*."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
# ---------------------------------------------------------------------------
# managed_uv_path
# ---------------------------------------------------------------------------
class TestManagedUvPath:
def test_posix(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv"
def test_windows(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv.exe"
# ---------------------------------------------------------------------------
# resolve_uv
# ---------------------------------------------------------------------------
class TestResolveUv:
def test_missing_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
def test_existing_executable(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
result = resolve_uv()
assert result == str(tmp_path / "bin" / "uv")
def test_non_executable_file_returns_none(self, tmp_path):
uv = tmp_path / "bin" / "uv"
uv.parent.mkdir(parents=True)
uv.write_text("not a binary")
# Ensure no execute bit
uv.chmod(0o644)
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
# ---------------------------------------------------------------------------
# ensure_uv
# ---------------------------------------------------------------------------
class TestEnsureUv:
def test_already_installed_no_bootstrap(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import ensure_uv
path = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
def test_installs_if_missing(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv") as mock_install:
# Simulate the installer creating the binary
def fake_install(target):
_make_executable(target)
mock_install.side_effect = fake_install
from hermes_cli.managed_uv import ensure_uv
path = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
mock_install.assert_called_once()
def test_install_failure_returns_falsy(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
path = ensure_uv()
# Failure is a falsy sentinel (not None) so legacy 2-target call
# sites can still unpack it without raising — see
# TestEnsureUvUpdateBoundary for why.
assert not path
class TestEnsureUvUpdateBoundary:
"""``ensure_uv()`` must answer to both the single-value and the legacy
``(path, fresh_bootstrap)`` call conventions — **on POSIX**.
``hermes update`` runs the call site from the old, already-imported
``hermes_cli.main`` against the freshly pulled ``managed_uv``. A release
parked on a ``(path, fresh)`` tuple runs ``uv_bin, fresh = ensure_uv()``
against the single-value module; the path is an iterable ``str`` so the
2-target unpack walked its characters and raised
``ValueError: too many values to unpack (expected 2)`` (root cause behind
PR #39763), or ``TypeError`` on the ``None`` failure path. On POSIX the
result must therefore be usable as a bare path *and* unpackable as a
2-tuple, in both the success and failure cases.
The dual contract is intentionally **not** offered on Windows — see
``TestEnsureUvWindowsSafe`` for why — so these tests pin ``platform.system``
to a POSIX value.
"""
def test_success_usable_as_single_value(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
assert uv_bin == str(tmp_path / "bin" / "uv")
assert bool(uv_bin) is True
def test_success_unpacks_as_legacy_two_tuple(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import ensure_uv
uv_bin, fresh = ensure_uv() # old: uv_bin, fresh_bootstrap = ensure_uv()
assert uv_bin == str(tmp_path / "bin" / "uv")
assert fresh is False
def test_failure_unpacks_without_raising(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
uv_bin, fresh = ensure_uv()
assert uv_bin is None
assert fresh is False
class TestEnsureUvWindowsSafe:
"""On Windows ``ensure_uv()`` must return a plain ``str``/``None``.
``subprocess`` on Windows serializes argv through
``subprocess.list2cmdline``, which iterates every entry *as a string*
(``for c in arg``). The dependency installer feeds uv straight into the
command list (``[uv_bin, "pip", "install", ...]``). A ``str`` subclass
whose ``__iter__`` yields ``(path, fresh_bootstrap)`` instead of characters
therefore injects the bool into the command line and crashes the install
with ``TypeError: sequence item 1: expected str instance, bool found``
(a real field report on a 10-commits-behind Windows install). A single
return value cannot serve both the legacy 2-tuple unpack and Windows
char-iteration — both use the iterator protocol — so Windows opts out of
the wrapper entirely.
"""
def test_uvresult_would_break_windows_list2cmdline(self):
# Canary: this is *why* the wrapper is gated off Windows. If a future
# change makes _UvResult char-iterable (and thus list2cmdline-safe),
# the gate may be revisited.
import subprocess
from hermes_cli.managed_uv import _UvResult
with pytest.raises(TypeError):
subprocess.list2cmdline([_UvResult("C:\\hermes\\uv.exe"), "pip"])
def test_windows_returns_plain_str_safe_for_subprocess(self, tmp_path):
import subprocess
# On (mocked) Windows the managed binary is uv.exe.
_make_executable(tmp_path / "bin" / "uv.exe")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
from hermes_cli.managed_uv import _UvResult, ensure_uv
uv_bin = ensure_uv()
assert type(uv_bin) is str and not isinstance(uv_bin, _UvResult)
# The exact operation that crashed in the field must now succeed.
cmdline = subprocess.list2cmdline([uv_bin, "pip", "install", "-e", "."])
assert "pip" in cmdline and "install" in cmdline
def test_windows_failure_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
assert ensure_uv() is None
# ---------------------------------------------------------------------------
# update_managed_uv
# ---------------------------------------------------------------------------
class TestUpdateManagedUv:
def test_no_uv_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import update_managed_uv
assert update_managed_uv() is None
def test_self_update_success(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
# uv self update succeeds
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
assert result == str(tmp_path / "bin" / "uv")
# First call is self update, second is --version
assert mock_run.call_count == 2
assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"]
def test_self_update_failure_non_fatal(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
# Still returns the path — failure is non-fatal
assert result == str(tmp_path / "bin" / "uv")
# ---------------------------------------------------------------------------
# _install_uv internals
# ---------------------------------------------------------------------------
class TestInstallUvInternals:
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
target = tmp_path / "bin" / "uv"
with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_posix.assert_called_once()
call_env = mock_posix.call_args[0][0]
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
def test_windows_sets_uv_install_dir(self, tmp_path):
target = tmp_path / "bin" / "uv.exe"
with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_windows.assert_called_once()
call_env = mock_windows.call_args[0][0]
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")