test(managed-uv): use native Windows fixtures and isolated stamps
This commit is contained in:
@@ -16,6 +16,12 @@ import pytest
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Host-native managed-uv binary name: managed_uv_path() installs `uv` on
|
||||
# POSIX and `uv.exe` on Windows. Fixtures must build what the real host
|
||||
# resolves — no platform fake.
|
||||
_UV_BINARY_NAME = "uv.exe" if sys.platform == "win32" else "uv"
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Create a minimal fake uv binary at *path*."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -158,11 +164,12 @@ class TestMacOSManagedPythonSigning:
|
||||
class TestResolveUv:
|
||||
|
||||
def test_existing_executable(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
uv = tmp_path / "bin" / _UV_BINARY_NAME
|
||||
_make_executable(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")
|
||||
assert result == str(uv)
|
||||
|
||||
def test_non_executable_file_returns_none(self, tmp_path):
|
||||
uv = tmp_path / "bin" / "uv"
|
||||
@@ -182,17 +189,20 @@ class TestResolveUv:
|
||||
class TestEnsureUv:
|
||||
|
||||
def test_installs_if_missing(self, tmp_path):
|
||||
uv = tmp_path / "bin" / _UV_BINARY_NAME
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
||||
patch("hermes_cli.managed_uv._uv_version", return_value="uv 0.1.2"), \
|
||||
patch("hermes_cli.managed_uv._install_uv") as mock_install:
|
||||
# Simulate the installer creating the binary
|
||||
# Simulate the installer creating the binary (host-native name:
|
||||
# uv.exe on Windows, uv on POSIX).
|
||||
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")
|
||||
assert path == str(uv)
|
||||
mock_install.assert_called_once()
|
||||
|
||||
def test_install_reports_runtime_repair_to_observer(self, tmp_path):
|
||||
@@ -217,13 +227,16 @@ class TestEnsureUv:
|
||||
), patch(
|
||||
"hermes_cli.managed_uv._install_uv",
|
||||
side_effect=fake_install,
|
||||
), patch(
|
||||
"hermes_cli.managed_uv._uv_version",
|
||||
return_value="uv 0.1.2",
|
||||
), patch(
|
||||
"hermes_cli.managed_uv.repair_vulnerable_runtime",
|
||||
return_value=repair,
|
||||
):
|
||||
path = ensure_uv(repair_observer=observed.append)
|
||||
|
||||
assert path == str(tmp_path / "bin" / "uv")
|
||||
assert path == str(tmp_path / "bin" / _UV_BINARY_NAME)
|
||||
assert observed == [repair]
|
||||
|
||||
|
||||
@@ -329,25 +342,30 @@ class TestUpdateManagedUv:
|
||||
|
||||
|
||||
|
||||
def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path, monkeypatch):
|
||||
def test_fresh_stamp_skips_network_self_update_but_not_repair(self, tmp_path):
|
||||
"""A recent success stamp must skip `uv self update` entirely while the
|
||||
vulnerable-runtime repair probe still runs (CVE repair is never gated)."""
|
||||
import time
|
||||
|
||||
from hermes_cli.managed_uv import RuntimeRepairResult, update_managed_uv
|
||||
|
||||
uv = tmp_path / "bin" / "uv"
|
||||
uv = tmp_path / "bin" / _UV_BINARY_NAME
|
||||
_make_executable(uv)
|
||||
# Fresh stamp under the isolated HERMES_HOME.
|
||||
import hermes_constants
|
||||
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
|
||||
# The stamp reader imports get_hermes_home separately from the binary
|
||||
# resolver. Give both paths the same explicit test root.
|
||||
stamp = tmp_path / "cache" / ".uv_self_update_stamp"
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.touch()
|
||||
# File timestamps can lead time.time() briefly on Windows. Stay well
|
||||
# inside the freshness window instead of racing its age >= 0 boundary.
|
||||
recent = time.time() - 60
|
||||
os.utime(stamp, (recent, recent))
|
||||
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
|
||||
patch(
|
||||
"hermes_cli.managed_uv.repair_vulnerable_runtime",
|
||||
return_value=RuntimeRepairResult("skipped"),
|
||||
) as mock_repair:
|
||||
patch("hermes_cli.managed_uv._uv_self_update_stamp", return_value=stamp), \
|
||||
patch("hermes_cli.managed_uv.repair_vulnerable_runtime",
|
||||
return_value=RuntimeRepairResult("skipped")) as mock_repair, \
|
||||
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
||||
result = update_managed_uv()
|
||||
|
||||
assert result == str(uv)
|
||||
@@ -361,17 +379,19 @@ class TestUpdateManagedUv:
|
||||
|
||||
from hermes_cli.managed_uv import UV_SELF_UPDATE_INTERVAL_SECONDS, update_managed_uv
|
||||
|
||||
uv = tmp_path / "bin" / "uv"
|
||||
uv = tmp_path / "bin" / _UV_BINARY_NAME
|
||||
_make_executable(uv)
|
||||
import hermes_constants
|
||||
stamp = hermes_constants.get_hermes_home() / "cache" / ".uv_self_update_stamp"
|
||||
# Keep the stamp and binary resolver in the same test root.
|
||||
stamp = tmp_path / "cache" / ".uv_self_update_stamp"
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.touch()
|
||||
old = _time.time() - UV_SELF_UPDATE_INTERVAL_SECONDS - 60
|
||||
_os.utime(stamp, (old, old))
|
||||
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv._uv_self_update_stamp", return_value=stamp), \
|
||||
patch("hermes_cli.managed_uv.repair_vulnerable_runtime", return_value=_RRR("not-applicable")), \
|
||||
patch("hermes_cli.managed_uv._uv_version", return_value="uv 0.2.0"), \
|
||||
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
|
||||
update_managed_uv()
|
||||
@@ -464,47 +484,6 @@ class TestRuntimeRepair:
|
||||
assert not (root / ".hermes-runtime").exists()
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_stage_candidate_sync_keeps_uv_project_config(self, tmp_path):
|
||||
from hermes_cli.managed_uv import _stage_candidate_venv
|
||||
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
(root / "uv.lock").write_text("# lock\n", encoding="utf-8")
|
||||
generation = root / ".hermes-runtime" / "python" / "gen"
|
||||
python = generation / "bin" / "python"
|
||||
python.parent.mkdir(parents=True)
|
||||
python.write_text("py", encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append((list(argv), kwargs.get("env")))
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
||||
patch(
|
||||
"hermes_cli.managed_uv._smoke_candidate_venv",
|
||||
return_value=(True, "", None),
|
||||
):
|
||||
candidate = _stage_candidate_venv(
|
||||
"uv",
|
||||
project_root=root,
|
||||
generation=generation,
|
||||
python=python,
|
||||
)
|
||||
|
||||
assert candidate is not None
|
||||
assert len(calls) == 2
|
||||
venv_argv, venv_env = calls[0]
|
||||
sync_argv, sync_env = calls[1]
|
||||
assert venv_argv[:2] == ["uv", "venv"]
|
||||
assert "--no-config" in venv_argv
|
||||
assert venv_env.get("UV_NO_CONFIG") == "1"
|
||||
assert sync_argv[:2] == ["uv", "sync"]
|
||||
assert "--locked" in sync_argv
|
||||
assert "--no-config" not in sync_argv
|
||||
assert "UV_NO_CONFIG" not in sync_env
|
||||
|
||||
def test_failed_candidate_preserves_live_venv(self, tmp_path):
|
||||
from hermes_cli.managed_uv import (
|
||||
_acquire_repair_lock,
|
||||
@@ -620,6 +599,54 @@ class TestRuntimeRepair:
|
||||
assert leftovers == [], f"no stale markers may remain: {leftovers}"
|
||||
|
||||
|
||||
class TestStageCandidateVenvCrossPlatform:
|
||||
"""Candidate sync preserves project config and streams progress on every host."""
|
||||
|
||||
def test_sync_keeps_uv_project_config_and_merges_stderr(self, tmp_path):
|
||||
import subprocess
|
||||
|
||||
from hermes_cli.managed_uv import _stage_candidate_venv
|
||||
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
(root / "uv.lock").write_text("# lock\n", encoding="utf-8")
|
||||
generation = root / ".hermes-runtime" / "python" / "gen"
|
||||
python = generation / "bin" / "python"
|
||||
python.parent.mkdir(parents=True)
|
||||
python.write_text("py", encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append((list(argv), kwargs))
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
||||
patch(
|
||||
"hermes_cli.managed_uv._smoke_candidate_venv",
|
||||
return_value=(True, "", None),
|
||||
):
|
||||
candidate = _stage_candidate_venv(
|
||||
"uv",
|
||||
project_root=root,
|
||||
generation=generation,
|
||||
python=python,
|
||||
)
|
||||
|
||||
assert candidate is not None
|
||||
assert len(calls) == 2
|
||||
venv_argv, venv_kwargs = calls[0]
|
||||
sync_argv, sync_kwargs = calls[1]
|
||||
assert venv_argv[:2] == ["uv", "venv"]
|
||||
assert "--no-config" in venv_argv
|
||||
assert venv_kwargs["env"].get("UV_NO_CONFIG") == "1"
|
||||
assert sync_argv[:2] == ["uv", "sync"]
|
||||
assert "--locked" in sync_argv
|
||||
assert "--no-config" not in sync_argv
|
||||
assert "UV_NO_CONFIG" not in sync_kwargs["env"]
|
||||
assert sync_kwargs["stderr"] == subprocess.STDOUT
|
||||
|
||||
|
||||
class TestRuntimeCutover:
|
||||
def test_os_lock_blocks_concurrent_repair_and_releases(self, tmp_path):
|
||||
from hermes_cli.managed_uv import _acquire_repair_lock, _release_repair_lock
|
||||
@@ -674,13 +701,23 @@ class TestRuntimeCutover:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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]
|
||||
def test_installer_uses_host_branch_and_managed_directory(self, tmp_path):
|
||||
"""The native installer receives the managed directory, not a PATH default."""
|
||||
import hermes_cli.managed_uv as managed_uv
|
||||
|
||||
target = tmp_path / "bin" / _UV_BINARY_NAME
|
||||
with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix, \
|
||||
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
|
||||
managed_uv._install_uv(target)
|
||||
|
||||
host_installer, other_installer = (
|
||||
(mock_windows, mock_posix) if sys.platform == "win32"
|
||||
else (mock_posix, mock_windows))
|
||||
host_installer.assert_called_once()
|
||||
other_installer.assert_not_called()
|
||||
call_env = host_installer.call_args[0][0]
|
||||
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")
|
||||
if sys.platform != "win32":
|
||||
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
|
||||
|
||||
|
||||
@@ -1252,10 +1289,16 @@ class TestDefaultLiveVenv:
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
(root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
||||
# Host-native venv layout: bin/python on POSIX, Scripts/python.exe on
|
||||
# Windows — what _venv_python() resolves on the real host.
|
||||
if sys.platform == "win32":
|
||||
bin_dir_name, python_name = "Scripts", "python.exe"
|
||||
else:
|
||||
bin_dir_name, python_name = "bin", "python"
|
||||
for d in dirs:
|
||||
bin_dir = root / d / "bin"
|
||||
bin_dir = root / d / bin_dir_name
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "python").write_text("py", encoding="utf-8")
|
||||
(bin_dir / python_name).write_text("py", encoding="utf-8")
|
||||
return root
|
||||
|
||||
def test_dot_venv_only_is_targeted(self, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user