diff --git a/hermes_cli/main.py b/hermes_cli/main.py index afb7ce9f7..08e610d17 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8029,20 +8029,12 @@ def _update_via_zip(args): # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current — runs `uv self update` if we already have one. update_managed_uv() - uv_bin, fresh_bootstrap = ensure_uv() - # First-time managed uv install on an existing checkout: the old venv - # may point to a Python without FTS5. Rebuild it so the new managed - # uv provides a fresh interpreter with FTS5 guaranteed. - if fresh_bootstrap and uv_bin: - if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): - raise RuntimeError( - "venv rebuild failed; aborting update before dependency install" - ) + uv_bin = ensure_uv() pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -8697,48 +8689,6 @@ def _venv_scripts_dir() -> Path | None: return scripts if scripts.is_dir() else None -def _wait_for_interpreter_venv_ready(*, timeout: float = 15.0) -> bool: - """Ensure the venv hosting ``sys.executable`` has an intact ``pyvenv.cfg``. - - During ``hermes update`` the managed-uv path can rebuild the project venv - (``rebuild_venv`` → ``shutil.rmtree`` + ``uv venv``) before the - desktop-rebuild and profile-skills-sync steps run. Both of those steps - spawn a child process with ``sys.executable``. If they fire while the venv - is mid-rewrite, the interpreter launcher finds the venv directory but no - ``pyvenv.cfg`` yet and aborts with the bare stderr line - ``No pyvenv.cfg file`` — surfacing as a spurious "Desktop build failed" / - "sync failed" on an update that otherwise succeeded. - - A venv's ``pyvenv.cfg`` sits one level up from the interpreter's ``bin`` / - ``Scripts`` dir. If ``sys.executable`` is NOT a venv interpreter (no - sibling marker dir, e.g. a system Python on PATH), there is nothing to - wait for and we return True immediately. Otherwise we poll briefly for the - marker to (re)appear — the rewrite window is short — and return whether - it's present. Best-effort: never raises, callers proceed regardless. - """ - try: - exe = Path(sys.executable).resolve() - except Exception: - return True - - venv_dir = exe.parent.parent # .../venv/{bin,Scripts}/python -> .../venv - bin_dir = venv_dir / ("Scripts" if _is_windows() else "bin") - if not bin_dir.is_dir(): - # Not a venv-hosted interpreter — pyvenv.cfg is irrelevant. - return True - - cfg = venv_dir / "pyvenv.cfg" - if cfg.is_file(): - return True - - deadline = _time.monotonic() + max(0.0, timeout) - while _time.monotonic() < deadline: - if cfg.is_file(): - return True - _time.sleep(0.25) - return cfg.is_file() - - def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: """Entry-point shims that uv may try to rewrite during ``pip install -e .``. @@ -10133,7 +10083,7 @@ def _cmd_update_pip(args): # Keep managed uv current before using it. update_managed_uv() - uv, _fresh_bootstrap = ensure_uv() + uv = ensure_uv() in_venv = sys.prefix != sys.base_prefix # pipx-managed installs live under .../pipx/venvs//... pipx_managed = "pipx" in sys.prefix.split(os.sep) @@ -10575,20 +10525,12 @@ def _cmd_update_impl(args, gateway_mode: bool): # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current — runs `uv self update` if we already have one. update_managed_uv() - uv_bin, fresh_bootstrap = ensure_uv() - # First-time managed uv install on an existing checkout: the old venv - # may point to a Python without FTS5. Rebuild it so the new managed - # uv provides a fresh interpreter with FTS5 guaranteed. - if fresh_bootstrap and uv_bin: - if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): - raise RuntimeError( - "venv rebuild failed; aborting update before dependency install" - ) + uv_bin = ensure_uv() pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -10651,18 +10593,13 @@ def _cmd_update_impl(args, gateway_mode: bool): has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir) if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app: print("→ Checking if desktop app needs rebuilding...") - # The Python-dependency step above may have rebuilt the venv that - # hosts sys.executable. Wait for its pyvenv.cfg to settle before - # spawning, or the child interpreter aborts with "No pyvenv.cfg - # file" and the rebuild spuriously "fails" on a successful update. - _wait_for_interpreter_venv_ready() _desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"] # Stream the build output live (long Electron builds otherwise # look hung). On the rare nonzero exit, retry once after waiting # again for the venv — this covers a still-settling rebuild window # the first wait didn't fully catch. build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False) - if build_result.returncode != 0 and _wait_for_interpreter_venv_ready(): + if build_result.returncode != 0: build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False) if build_result.returncode != 0: print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)") @@ -10719,10 +10656,6 @@ def _cmd_update_impl(args, gateway_mode: bool): if all_profiles: print() print("→ Syncing bundled skills to all profiles...") - # seed_profile_skills spawns sys.executable; if the venv was - # just rebuilt above, wait for pyvenv.cfg before the loop so - # the children don't abort with "No pyvenv.cfg file". - _wait_for_interpreter_venv_ready() for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 722cf5ba3..3f027e73c 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -6,11 +6,6 @@ If the binary is missing, ``ensure_uv()`` bootstraps it via the official standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH probing, no conda guards, no multi-location resolution chains. - -When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no -managed uv before), it returns ``(path, True)`` instead of just ``path``. -Callers in the update path use that signal to nuke and recreate the venv -with the now-current managed uv, guaranteeing a Python with FTS5. """ from __future__ import annotations @@ -22,7 +17,7 @@ import shutil import subprocess import tempfile from pathlib import Path -from typing import Optional, Tuple +from typing import Optional from hermes_constants import get_hermes_home @@ -56,20 +51,15 @@ def resolve_uv() -> Optional[str]: return None -def ensure_uv() -> Tuple[Optional[str], bool]: +def ensure_uv() -> Optional[str]: """Return the managed uv path, installing it first if necessary. - Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is - ``True`` when we just installed managed uv for the first time (there was - no managed uv before this call). Callers can use that signal to rebuild - the venv so Python is guaranteed to have FTS5. - - On failure returns ``(None, False)`` (never raises) so callers can fall + On failure returns ``None`` (never raises) so callers can fall back to pip gracefully. """ existing = resolve_uv() if existing: - return (existing, False) + return existing target = managed_uv_path() target.parent.mkdir(parents=True, exist_ok=True) @@ -81,7 +71,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]: except Exception as exc: logger.warning("Managed uv install failed: %s", exc) print(f" ✗ Failed to install managed uv: {exc}") - return (None, False) + return None # Verify result = resolve_uv() @@ -95,95 +85,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]: print(f" ✓ Managed uv installed ({version})") else: print(" ✗ Managed uv install appeared to succeed but binary not found") - return (result, result is not None) - - -def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: - """Nuke and recreate the venv with managed uv. - - Called when managed uv is first bootstrapped on an existing install — the - old venv may point to a Python without FTS5, so we rebuild it with a - fresh interpreter from the current managed uv. Returns ``True`` on - success. - - The old venv is moved aside *atomically* (``os.replace`` to ``.old``) - before recreating — never deleted in place. On Windows a still-running - ``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open; - ``shutil.rmtree(ignore_errors=True)`` would delete everything it *can* - (site-packages, certifi's cert bundle) and silently leave a half-gutted - venv that the following ``uv venv`` then refuses to overwrite ("directory - already exists") — bricking the install with no recovery (every later HTTPS - call dies with ``FileNotFoundError`` for the missing cert bundle). - ``--clear`` alone does not fix this: when the locked interpreter is *inside* - the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can - delete the held ``python.exe``. ``os.replace`` of the parent directory *is* - allowed (Windows tracks a running ``.exe`` by handle, not path), so the - rebuild completes while the running process keeps using the moved-aside copy - until it restarts. If the venv genuinely cannot be moved, we abort cleanly - and leave it fully intact; and if the rebuild itself fails we move the old - venv back so Hermes is never left with no venv at all. - """ - backup: Optional[Path] = None - if venv_dir.exists(): - print(f" → Rebuilding venv (old Python may lack FTS5)...") - backup = venv_dir.with_name(venv_dir.name + ".old") - shutil.rmtree(backup, ignore_errors=True) # clear any stale backup - try: - # Atomic move — fails (without partial deletion) if a process still - # holds files inside the venv, which is exactly the Windows - # file-lock case that previously bricked the install. - os.replace(venv_dir, backup) - except OSError as exc: - logger.warning("venv rebuild aborted — venv in use: %s", exc) - print( - " ✗ venv rebuild aborted — the venv is in use; stop the " - f"gateway/desktop and retry ({exc})" - ) - return False - - result = subprocess.run( - [uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"], - capture_output=True, - text=True, - check=False, - ) - - def _restore_backup() -> None: - if backup is not None and backup.exists(): - shutil.rmtree(venv_dir, ignore_errors=True) - try: - os.replace(backup, venv_dir) - print(" ↩ Restored previous venv after failed rebuild.") - except OSError: - pass - - if result.returncode == 0: - venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" - # uv can exit 0 yet leave no usable interpreter (e.g. a half-written - # venv). Don't report success on a venv that has no python — restore the - # moved-aside copy so the caller can abort without losing a working env. - if not venv_python.exists(): - logger.warning("venv rebuild reported success but %s is missing", venv_python) - print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}") - _restore_backup() - return False - if backup is not None: - shutil.rmtree(backup, ignore_errors=True) - py_ver = subprocess.run( - [str(venv_python), "--version"], - capture_output=True, - text=True, - check=False, - ).stdout.strip() - print(f" ✓ venv rebuilt ({py_ver})") - return True - else: - # Rebuild failed — restore the old venv so we never leave Hermes with no - # venv (the bricked-install failure mode this function exists to avoid). - _restore_backup() - logger.warning("venv rebuild failed: %s", result.stderr) - print(f" ✗ venv rebuild failed: {result.stderr.strip()}") - return False + return result def update_managed_uv() -> Optional[str]: diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index bcf92c5e7..d8204524c 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -59,19 +59,14 @@ def _patch_managed_uv(request): return shutil.which("uv") def _fake_ensure_uv(): - path = shutil.which("uv") - return (path, False) # never freshly bootstrapped in tests + return shutil.which("uv") def _fake_update_managed_uv(): return None # never actually self-update in tests - def _fake_rebuild_venv(*args, **kwargs): - return True # no-op in tests - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ - patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): yield diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index aff6b8de7..a9e9b92a6 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -76,11 +76,10 @@ class TestEnsureUv: _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, fresh = ensure_uv() + path = ensure_uv() assert path == str(tmp_path / "bin" / "uv") - assert fresh is False - def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path): + 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 @@ -89,127 +88,16 @@ class TestEnsureUv: mock_install.side_effect = fake_install from hermes_cli.managed_uv import ensure_uv - path, fresh = ensure_uv() + path = ensure_uv() assert path == str(tmp_path / "bin" / "uv") - assert fresh is True mock_install.assert_called_once() - def test_install_failure_returns_none_false(self, tmp_path): + def test_install_failure_returns_none(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, fresh = ensure_uv() + path = ensure_uv() assert path is None - assert fresh is False - - -# --------------------------------------------------------------------------- -# rebuild_venv -# --------------------------------------------------------------------------- - -class TestRebuildVenv: - def test_moves_old_venv_aside_and_creates_new(self, tmp_path): - """The old venv is moved aside to .old (never rmtree'd in place), - uv is invoked with --clear, the moved-aside backup is removed on - success, and the rebuilt interpreter is reported.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "old_file").write_text("stale") - - uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - m = MagicMock(returncode=0, stderr="", stdout="") - if len(cmd) >= 2 and cmd[1] == "venv": - # Simulate uv creating the venv dir with a python interpreter - bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin") - bin_dir.mkdir(parents=True, exist_ok=True) - python_name = "python.exe" if os.name == "nt" else "python" - (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0") - elif "--version" in cmd: - m.stdout = "Python 3.11.0" - return m - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is True - # uv venv was invoked exactly once, always with --clear. - venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] - assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}" - assert "--clear" in venv_calls[0] - # The moved-aside backup is cleaned up after a successful rebuild. - assert not (tmp_path / "venv.old").exists() - - def test_aborts_without_deleting_when_venv_in_use(self, tmp_path): - """If os.replace fails (Windows file lock — venv in use), we must abort - cleanly WITHOUT deleting the venv and WITHOUT invoking uv.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "locked") .write_text("held open") - uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - return MagicMock(returncode=0, stderr="", stdout="") - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is False - # venv left fully intact, uv never invoked. - assert venv_dir.exists() and (venv_dir / "locked").exists() - assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == [] - - def test_restores_backup_when_rebuild_fails(self, tmp_path): - """If uv venv exits non-zero, the moved-aside venv is restored so we - never leave Hermes with no venv at all.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "marker").write_text("original") - uv_bin = str(tmp_path / "bin" / "uv") - - def fake_run(cmd, **kwargs): - return MagicMock(returncode=1, stderr="boom", stdout="") - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is False - # Original venv restored from the .old backup. - assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original" - assert not (tmp_path / "venv.old").exists() - - def test_rebuild_failure_returns_false(self, tmp_path): - venv_dir = tmp_path / "venv" - uv_bin = str(tmp_path / "bin" / "uv") - - with 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 rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - assert result is False - - def test_rebuild_success_without_python_returns_false(self, tmp_path): - """uv can exit 0 yet leave no interpreter; that must not count as success - (guard adapted from #38511).""" - venv_dir = tmp_path / "venv" - uv_bin = str(tmp_path / "bin" / "uv") - - with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - assert result is False - # Returned before the `python --version` probe ran (only the uv venv call). - assert mock_run.call_count == 1 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index b29179077..6cd08a439 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -29,19 +29,14 @@ def _patch_managed_uv(request): return shutil.which("uv") def _fake_ensure_uv(): - path = shutil.which("uv") - return (path, False) # never freshly bootstrapped in tests + return shutil.which("uv") def _fake_update_managed_uv(): return None # never actually self-update in tests - def _fake_rebuild_venv(*args, **kwargs): - return True # no-op in tests - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ - patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): yield def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path): @@ -423,41 +418,6 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): assert ".[all]" in install_cmds[0] -def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path): - """A failed fresh managed-uv venv rebuild must not continue into pip install - (guard adapted from #38511).""" - _setup_update_mocks(monkeypatch, tmp_path) - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) - monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False) - - recorded = [] - - def fake_run(cmd, **kwargs): - recorded.append(cmd) - # Tolerant matching: the update flow's exact git invocations vary by - # checkout, so key off the verb. Branch detection must return a real name - # and rev-list a parseable count, or the flow aborts early before it ever - # reaches the venv rebuild this test exercises. - if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "git": - if "rev-parse" in cmd: - return SimpleNamespace(stdout="main\n", stderr="", returncode=0) - if "rev-list" in cmd: - return SimpleNamespace(stdout="1\n", stderr="", returncode=0) - if "pull" in cmd: - return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) - - with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \ - patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \ - pytest.raises(RuntimeError, match="venv rebuild failed"): - hermes_main.cmd_update(SimpleNamespace()) - - install_cmds = [c for c in recorded if "pip" in c and "install" in c] - assert install_cmds == [] - - def test_install_with_optional_fallback_honors_custom_group(monkeypatch): """Termux update path should target .[termux-all] when requested.""" calls = [] diff --git a/tests/hermes_cli/test_update_venv_ready.py b/tests/hermes_cli/test_update_venv_ready.py deleted file mode 100644 index 69cf1f4f6..000000000 --- a/tests/hermes_cli/test_update_venv_ready.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests for ``_wait_for_interpreter_venv_ready`` in ``hermes_cli/main.py``. - -During ``hermes update`` the managed-uv path can rebuild the project venv -(rmtree + ``uv venv``) before the desktop-rebuild and profile-skills-sync -steps spawn ``sys.executable``. If those children fire while the venv is -mid-rewrite, the interpreter launcher aborts with ``No pyvenv.cfg file`` and -the step spuriously "fails" on an otherwise-successful update. The helper -waits for the marker to settle first. -""" - -from __future__ import annotations - -import os -import threading -import time -from pathlib import Path - -from hermes_cli.main import _wait_for_interpreter_venv_ready - - -def _make_fake_venv(tmp_path: Path, *, with_cfg: bool) -> Path: - """Create a venv-shaped dir and return the interpreter path inside it.""" - bin_name = "Scripts" if os.name == "nt" else "bin" - bin_dir = tmp_path / bin_name - bin_dir.mkdir(parents=True) - py = bin_dir / ("python.exe" if os.name == "nt" else "python") - py.write_text("#!/bin/sh\n") - if with_cfg: - (tmp_path / "pyvenv.cfg").write_text("home = /usr\n") - return py - - -class TestWaitForInterpreterVenvReady: - def test_intact_venv_returns_immediately(self, tmp_path, monkeypatch): - py = _make_fake_venv(tmp_path, with_cfg=True) - monkeypatch.setattr("sys.executable", str(py)) - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=5) is True - assert time.monotonic() - t0 < 0.5 - - def test_non_venv_interpreter_returns_immediately(self, tmp_path, monkeypatch): - # A bare interpreter whose parent.parent has no bin/Scripts marker - # dir is not venv-hosted; pyvenv.cfg is irrelevant. - sys_py = tmp_path / "usr" / "bin" / "python" - sys_py.parent.mkdir(parents=True) - sys_py.write_text("#!/bin/sh\n") - # Ensure parent.parent (tmp_path/usr) has no bin sibling shaped like a venv - monkeypatch.setattr("sys.executable", str(sys_py)) - # parent.parent == tmp_path/usr; its "bin" child IS tmp_path/usr/bin - # which exists — so this would look venv-ish. Use a deeper layout - # where parent.parent has no bin marker: - deep = tmp_path / "opt" / "py3" / "real" / "python" - deep.parent.mkdir(parents=True) - deep.write_text("#!/bin/sh\n") - monkeypatch.setattr("sys.executable", str(deep)) - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=5) is True - assert time.monotonic() - t0 < 0.5 - - def test_waits_for_cfg_to_appear(self, tmp_path, monkeypatch): - py = _make_fake_venv(tmp_path, with_cfg=False) - monkeypatch.setattr("sys.executable", str(py)) - - def _write_cfg_later(): - time.sleep(0.6) - (tmp_path / "pyvenv.cfg").write_text("home = /usr\n") - - th = threading.Thread(target=_write_cfg_later) - th.start() - try: - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=5) is True - elapsed = time.monotonic() - t0 - finally: - th.join() - assert 0.5 < elapsed < 2.0 - - def test_returns_false_when_cfg_never_appears(self, tmp_path, monkeypatch): - py = _make_fake_venv(tmp_path, with_cfg=False) - monkeypatch.setattr("sys.executable", str(py)) - t0 = time.monotonic() - assert _wait_for_interpreter_venv_ready(timeout=1) is False - assert 0.9 < time.monotonic() - t0 < 1.6 diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py index f1f2905ef..b8f294de3 100644 --- a/tests/hermes_cli/test_uv_tool_update.py +++ b/tests/hermes_cli/test_uv_tool_update.py @@ -40,19 +40,14 @@ def _patch_managed_uv(request): return shutil.which("uv") def _fake_ensure_uv(): - path = shutil.which("uv") - return (path, False) # never freshly bootstrapped in tests + return shutil.which("uv") def _fake_update_managed_uv(): return None # never actually self-update in tests - def _fake_rebuild_venv(*args, **kwargs): - return True # no-op in tests - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \ - patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv): + patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): yield