When 'hermes update' rebuilds the project venv (rmtree + uv venv on the first managed-uv migration), the desktop-rebuild and profile-skills-sync steps that follow both spawn sys.executable. Firing while the venv is mid-rewrite makes the child interpreter abort with the bare stderr line 'No pyvenv.cfg file', surfacing as a spurious 'Desktop build failed' / 'default: sync failed' on an update that actually succeeded. Add _wait_for_interpreter_venv_ready(): resolve the venv hosting sys.executable and poll briefly for pyvenv.cfg to (re)appear before each of those subprocess steps. No-op when the interpreter isn't venv-hosted. The desktop rebuild also retries once after re-waiting, and keeps streaming its output live (no capture). Best-effort throughout — callers proceed regardless, so a genuinely broken venv still surfaces the real error.
84 lines
3.4 KiB
Python
84 lines
3.4 KiB
Python
"""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
|