fix(install): scrap rebuild venv
This commit is contained in:
@ -8029,20 +8029,12 @@ def _update_via_zip(args):
|
|||||||
# individually so update does not silently strip working capabilities.
|
# individually so update does not silently strip working capabilities.
|
||||||
print("→ Updating Python dependencies...")
|
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.
|
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||||
update_managed_uv()
|
update_managed_uv()
|
||||||
|
|
||||||
uv_bin, fresh_bootstrap = ensure_uv()
|
uv_bin = 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
pip_cmd = [sys.executable, "-m", "pip"]
|
pip_cmd = [sys.executable, "-m", "pip"]
|
||||||
if not uv_bin:
|
if not uv_bin:
|
||||||
@ -8697,48 +8689,6 @@ def _venv_scripts_dir() -> Path | None:
|
|||||||
return scripts if scripts.is_dir() else 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]:
|
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
|
||||||
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
|
"""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.
|
# Keep managed uv current before using it.
|
||||||
update_managed_uv()
|
update_managed_uv()
|
||||||
|
|
||||||
uv, _fresh_bootstrap = ensure_uv()
|
uv = ensure_uv()
|
||||||
in_venv = sys.prefix != sys.base_prefix
|
in_venv = sys.prefix != sys.base_prefix
|
||||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
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
|
# breaks on this machine, keep base deps and reinstall the remaining extras
|
||||||
# individually so update does not silently strip working capabilities.
|
# individually so update does not silently strip working capabilities.
|
||||||
print("→ Updating Python dependencies...")
|
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.
|
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||||
update_managed_uv()
|
update_managed_uv()
|
||||||
|
|
||||||
uv_bin, fresh_bootstrap = ensure_uv()
|
uv_bin = 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
pip_cmd = [sys.executable, "-m", "pip"]
|
pip_cmd = [sys.executable, "-m", "pip"]
|
||||||
if not uv_bin:
|
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)
|
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:
|
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
|
||||||
print("→ Checking if desktop app needs rebuilding...")
|
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"]
|
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
|
||||||
# Stream the build output live (long Electron builds otherwise
|
# Stream the build output live (long Electron builds otherwise
|
||||||
# look hung). On the rare nonzero exit, retry once after waiting
|
# look hung). On the rare nonzero exit, retry once after waiting
|
||||||
# again for the venv — this covers a still-settling rebuild window
|
# again for the venv — this covers a still-settling rebuild window
|
||||||
# the first wait didn't fully catch.
|
# the first wait didn't fully catch.
|
||||||
build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False)
|
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)
|
build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False)
|
||||||
if build_result.returncode != 0:
|
if build_result.returncode != 0:
|
||||||
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
|
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:
|
if all_profiles:
|
||||||
print()
|
print()
|
||||||
print("→ Syncing bundled skills to all profiles...")
|
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:
|
for p in all_profiles:
|
||||||
try:
|
try:
|
||||||
r = seed_profile_skills(p.path, quiet=True)
|
r = seed_profile_skills(p.path, quiet=True)
|
||||||
|
|||||||
@ -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
|
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
|
||||||
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
|
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
|
||||||
probing, no conda guards, no multi-location resolution chains.
|
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
|
from __future__ import annotations
|
||||||
@ -22,7 +17,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
@ -56,20 +51,15 @@ def resolve_uv() -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ensure_uv() -> Tuple[Optional[str], bool]:
|
def ensure_uv() -> Optional[str]:
|
||||||
"""Return the managed uv path, installing it first if necessary.
|
"""Return the managed uv path, installing it first if necessary.
|
||||||
|
|
||||||
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
|
On failure returns ``None`` (never raises) so callers can fall
|
||||||
``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
|
|
||||||
back to pip gracefully.
|
back to pip gracefully.
|
||||||
"""
|
"""
|
||||||
existing = resolve_uv()
|
existing = resolve_uv()
|
||||||
if existing:
|
if existing:
|
||||||
return (existing, False)
|
return existing
|
||||||
|
|
||||||
target = managed_uv_path()
|
target = managed_uv_path()
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@ -81,7 +71,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Managed uv install failed: %s", exc)
|
logger.warning("Managed uv install failed: %s", exc)
|
||||||
print(f" ✗ Failed to install managed uv: {exc}")
|
print(f" ✗ Failed to install managed uv: {exc}")
|
||||||
return (None, False)
|
return None
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
result = resolve_uv()
|
result = resolve_uv()
|
||||||
@ -95,95 +85,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]:
|
|||||||
print(f" ✓ Managed uv installed ({version})")
|
print(f" ✓ Managed uv installed ({version})")
|
||||||
else:
|
else:
|
||||||
print(" ✗ Managed uv install appeared to succeed but binary not found")
|
print(" ✗ Managed uv install appeared to succeed but binary not found")
|
||||||
return (result, result is not None)
|
return result
|
||||||
|
|
||||||
|
|
||||||
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 ``<venv>.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
|
|
||||||
|
|
||||||
|
|
||||||
def update_managed_uv() -> Optional[str]:
|
def update_managed_uv() -> Optional[str]:
|
||||||
|
|||||||
@ -59,19 +59,14 @@ def _patch_managed_uv(request):
|
|||||||
return shutil.which("uv")
|
return shutil.which("uv")
|
||||||
|
|
||||||
def _fake_ensure_uv():
|
def _fake_ensure_uv():
|
||||||
path = shutil.which("uv")
|
return shutil.which("uv")
|
||||||
return (path, False) # never freshly bootstrapped in tests
|
|
||||||
|
|
||||||
def _fake_update_managed_uv():
|
def _fake_update_managed_uv():
|
||||||
return None # never actually self-update in tests
|
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), \
|
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.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.update_managed_uv", side_effect=_fake_update_managed_uv):
|
||||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -76,11 +76,10 @@ class TestEnsureUv:
|
|||||||
_make_executable(tmp_path / "bin" / "uv")
|
_make_executable(tmp_path / "bin" / "uv")
|
||||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
||||||
from hermes_cli.managed_uv import ensure_uv
|
from hermes_cli.managed_uv import ensure_uv
|
||||||
path, fresh = ensure_uv()
|
path = ensure_uv()
|
||||||
assert path == str(tmp_path / "bin" / "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), \
|
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||||
patch("hermes_cli.managed_uv._install_uv") as mock_install:
|
patch("hermes_cli.managed_uv._install_uv") as mock_install:
|
||||||
# Simulate the installer creating the binary
|
# Simulate the installer creating the binary
|
||||||
@ -89,127 +88,16 @@ class TestEnsureUv:
|
|||||||
mock_install.side_effect = fake_install
|
mock_install.side_effect = fake_install
|
||||||
|
|
||||||
from hermes_cli.managed_uv import ensure_uv
|
from hermes_cli.managed_uv import ensure_uv
|
||||||
path, fresh = ensure_uv()
|
path = ensure_uv()
|
||||||
assert path == str(tmp_path / "bin" / "uv")
|
assert path == str(tmp_path / "bin" / "uv")
|
||||||
assert fresh is True
|
|
||||||
mock_install.assert_called_once()
|
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), \
|
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")):
|
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
||||||
from hermes_cli.managed_uv import ensure_uv
|
from hermes_cli.managed_uv import ensure_uv
|
||||||
path, fresh = ensure_uv()
|
path = ensure_uv()
|
||||||
assert path is None
|
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 <venv>.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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -29,19 +29,14 @@ def _patch_managed_uv(request):
|
|||||||
return shutil.which("uv")
|
return shutil.which("uv")
|
||||||
|
|
||||||
def _fake_ensure_uv():
|
def _fake_ensure_uv():
|
||||||
path = shutil.which("uv")
|
return shutil.which("uv")
|
||||||
return (path, False) # never freshly bootstrapped in tests
|
|
||||||
|
|
||||||
def _fake_update_managed_uv():
|
def _fake_update_managed_uv():
|
||||||
return None # never actually self-update in tests
|
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), \
|
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.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.update_managed_uv", side_effect=_fake_update_managed_uv):
|
||||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path):
|
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]
|
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):
|
def test_install_with_optional_fallback_honors_custom_group(monkeypatch):
|
||||||
"""Termux update path should target .[termux-all] when requested."""
|
"""Termux update path should target .[termux-all] when requested."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -40,19 +40,14 @@ def _patch_managed_uv(request):
|
|||||||
return shutil.which("uv")
|
return shutil.which("uv")
|
||||||
|
|
||||||
def _fake_ensure_uv():
|
def _fake_ensure_uv():
|
||||||
path = shutil.which("uv")
|
return shutil.which("uv")
|
||||||
return (path, False) # never freshly bootstrapped in tests
|
|
||||||
|
|
||||||
def _fake_update_managed_uv():
|
def _fake_update_managed_uv():
|
||||||
return None # never actually self-update in tests
|
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), \
|
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.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.update_managed_uv", side_effect=_fake_update_managed_uv):
|
||||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user