fix(update): resolve the live venv from the running interpreter, not <checkout>/venv
An install whose interpreter lives outside the checkout ($HERMES_HOME/venvs/<name> — the layout the shipped Windows launchers assume) has neither venv/ nor .venv/ in-tree, so project_venv_dir() returned None and every `project_venv_dir(root) or root / "venv"` call site in the updater handed uv a VIRTUAL_ENV that does not exist (#116148): two uv interpreter errors per update, the import probe skipped, every `hermes tools` dependency reclassified as missing and reinstalled into a dead pointer, the staleness probe a no-op. project_venv_dir() — the single choke point — now falls back to the venv of the interpreter running this module, for the checkout it was loaded from only, so a foreign root (test temp dir, another clone) still resolves to None. In-tree venv/.venv still wins. Tests trimmed to two invariants in tests/test_hermes_constants.py. Salvages #116293; supersedes #116300 (same fix at the two VIRTUAL_ENV export sites). Co-authored-by: S <superb-cation@users.noreply.github.com>
This commit is contained in:
@@ -1458,15 +1458,35 @@ def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path:
|
||||
|
||||
|
||||
def project_venv_dir(project_root) -> Path | None:
|
||||
"""The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``).
|
||||
"""The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``);
|
||||
for an install whose interpreter lives outside the checkout, the running interpreter's venv.
|
||||
|
||||
``uv venv`` defaults to ``.venv`` while our installers create ``venv``, so both layouts are in the wild.
|
||||
Call sites that only knew about ``venv`` silently no-oped on a ``.venv`` install — that is how the
|
||||
Windows shim-lock preflight skipped itself entirely (#79542). ``venv`` wins when both exist, matching
|
||||
what the installers write.
|
||||
|
||||
Installers that keep the interpreter out of the checkout (``$HERMES_HOME/venvs/<name>``, the layout the
|
||||
shipped Windows launchers assume) have neither, and the ``project_venv_dir(root) or root / "venv"``
|
||||
idiom those call sites share then handed ``uv`` a ``VIRTUAL_ENV`` that does not exist: that one invented
|
||||
path skipped the import probe, reclassified every ``hermes tools`` dependency as missing and failed the
|
||||
reinstall with interpreter errors (#116148). The interpreter running this module is the only truthful
|
||||
answer to "which venv is live", so fall back to it — but only for the checkout it was loaded from. A
|
||||
foreign root (test temp dir, another clone) still resolves to ``None``: handing it someone else's venv
|
||||
would point the callers' writes at the wrong environment.
|
||||
"""
|
||||
root = Path(project_root)
|
||||
return next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None)
|
||||
in_tree = next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None)
|
||||
if in_tree is not None:
|
||||
return in_tree
|
||||
# Out-of-tree install: the path is real by construction (never invented), and non-venv installs
|
||||
# keep today's ``None`` so the ``or root / "venv"`` fallback cannot install into a base interpreter.
|
||||
running = Path(sys.prefix)
|
||||
if (Path(__file__).resolve().parent == root.resolve()
|
||||
and sys.prefix != sys.base_prefix
|
||||
and venv_python_path(running).is_file()):
|
||||
return running
|
||||
return None
|
||||
|
||||
|
||||
def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for hermes_constants module."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -1240,3 +1241,40 @@ class TestHealAttemptFlagSemantics:
|
||||
# The flag is set, so the once-per-process budget is spent.
|
||||
assert heal_hermes_managed_node() is False
|
||||
assert calls["n"] == 1
|
||||
|
||||
class TestProjectVenvDirOutOfTree:
|
||||
"""#116148: a checkout with no in-tree venv whose interpreter lives in ``$HERMES_HOME/venvs/<name>``
|
||||
(the layout the shipped Windows launchers pin) must resolve to the running interpreter's venv,
|
||||
never ``None`` — every updater call site turns ``None`` into a fabricated ``<checkout>/venv`` that
|
||||
uv cannot inspect, so tool dependencies are never refreshed."""
|
||||
|
||||
@staticmethod
|
||||
def _running_from(monkeypatch, checkout, venv):
|
||||
monkeypatch.setattr(hermes_constants, "__file__", str(checkout / "hermes_constants.py"))
|
||||
monkeypatch.setattr(sys, "prefix", str(venv))
|
||||
monkeypatch.setattr(sys, "base_prefix", str(checkout / "no-such-base"))
|
||||
|
||||
def test_out_of_tree_install_resolves_the_running_interpreter_venv(self, monkeypatch, tmp_path):
|
||||
checkout = tmp_path / "hermes-agent"
|
||||
checkout.mkdir()
|
||||
venv = tmp_path / "venvs" / "hermes"
|
||||
hermes_constants.venv_python_path(venv).parent.mkdir(parents=True)
|
||||
hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8")
|
||||
self._running_from(monkeypatch, checkout, venv)
|
||||
|
||||
assert hermes_constants.project_venv_dir(checkout) == venv
|
||||
|
||||
def test_foreign_root_and_in_tree_venv_are_unchanged(self, monkeypatch, tmp_path):
|
||||
"""A temp dir / another clone never claims the running venv; an in-tree venv still wins."""
|
||||
checkout = tmp_path / "hermes-agent"
|
||||
checkout.mkdir()
|
||||
venv = tmp_path / "venvs" / "hermes"
|
||||
hermes_constants.venv_python_path(venv).parent.mkdir(parents=True)
|
||||
hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8")
|
||||
self._running_from(monkeypatch, checkout, venv)
|
||||
other = tmp_path / "not-our-checkout"
|
||||
other.mkdir()
|
||||
|
||||
assert hermes_constants.project_venv_dir(other) is None
|
||||
(checkout / ".venv").mkdir()
|
||||
assert hermes_constants.project_venv_dir(checkout) == checkout / ".venv"
|
||||
|
||||
Reference in New Issue
Block a user