Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions hermes_cli/update_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd)
from hermes_cli.update_cmd_common import _best_effort
from hermes_constants import get_default_hermes_root, project_venv_dir, venv_python_path
from hermes_constants import get_default_hermes_root, project_venv_dir, running_venv_root, venv_python_path

# Re-exports: every split-module name stays reachable (and monkeypatchable) as update_cmd.<name>.
from hermes_cli.update_abort_recovery import ( # noqa: F401
Expand Down Expand Up @@ -677,6 +677,20 @@ def _repair_venv_on_current_checkout(
)


def _resolved_install_venv_dir() -> Path | None:
"""The venv root ``uv pip`` installs must target: the checkout's in-tree venv when one exists,
else the running interpreter's own venv, else ``None`` (uv/pip resolve from ``sys.executable``).

Never fabricate ``PROJECT_ROOT/venv``: on out-of-tree installs — interpreter under
``$HERMES_HOME\\venvs\\hermes``, no checkout ``venv/``, the layout the shipped Windows
gateway launchers pin themselves — that path does not exist, and uv aborts every command
with ``Failed to inspect Python interpreter from active virtual environment`` before doing
any work, so lazy refreshes and ``hermes tools`` dependency restores silently fail while
the update still reports success (#116148).
"""
return project_venv_dir(_m().PROJECT_ROOT) or running_venv_root()


def _pip_install_prefix(uv_bin) -> tuple[list[str], dict | None]:
"""``(install prefix, env)``: ``uv pip`` isolated from third-party UV env vars (so a foreign
UV_PYTHON_INSTALL_DIR can't hijack it), else ``sys.executable -m pip`` (avoids PEP 668 errors)."""
Expand All @@ -687,7 +701,9 @@ def _pip_install_prefix(uv_bin) -> tuple[list[str], dict | None]:
# See #83914.
from hermes_cli.managed_uv import managed_python_env
env = managed_python_env()
env["VIRTUAL_ENV"] = str(project_venv_dir(_m().PROJECT_ROOT) or _m().PROJECT_ROOT / "venv")
venv_dir = _resolved_install_venv_dir()
if venv_dir is not None:
env["VIRTUAL_ENV"] = str(venv_dir)
return [uv_bin, "pip"], env
return [sys.executable, "-m", "pip"], None

Expand Down
9 changes: 7 additions & 2 deletions hermes_cli/update_cmd_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from typing import Collection, Optional

from hermes_cli.update_cmd_common import _best_effort
from hermes_constants import project_venv_dir

# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")
Expand Down Expand Up @@ -389,8 +388,14 @@ def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
# Same UV-env isolation as the main update path: a user-level UV_PYTHON_INSTALL_DIR / UV_PYTHON
# from unrelated software must not steer which interpreter uv resolves here.
from hermes_cli.managed_uv import managed_python_env
from hermes_cli.update_cmd import _resolved_install_venv_dir
uv_env = managed_python_env()
uv_env["VIRTUAL_ENV"] = str(project_venv_dir(_m().PROJECT_ROOT) or _m().PROJECT_ROOT / "venv")
# Never fabricate a nonexistent PROJECT_ROOT/venv: out-of-tree installs abort uv
# before any work (#116148). Shared resolver: in-tree venv, else the running
# interpreter's venv, else unset.
venv_dir = _resolved_install_venv_dir()
if venv_dir is not None:
uv_env["VIRTUAL_ENV"] = str(venv_dir)
if _m()._is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
Expand Down
19 changes: 19 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,25 @@ def project_venv_dir(project_root) -> Path | None:
return next((root / n for n in ("venv", ".venv") if (root / n).is_dir()), None)


def running_venv_root() -> Path | None:
"""The venv root of the running interpreter when ``sys.executable`` lives inside one, else None.

``sys.executable`` sits in ``<root>/Scripts`` (Windows) or ``<root>/bin`` (POSIX) of a real
venv; ``pyvenv.cfg`` in the parent is what distinguishes that from a system interpreter
(``/usr/bin/python`` has the same parent-dir shape and must not read as a venv root).
On out-of-tree installs — the layout the shipped Windows gateway launchers pin via
``VIRTUAL_ENV`` (``$HERMES_HOME\\venvs\\hermes``) — this is the env the updater is actually
running from, and the target its ``uv pip`` installs must reach (#116148).
"""
try:
root = Path(sys.executable).parent.parent
if (root / "pyvenv.cfg").is_file():
return root
except Exception:
return None
return None


def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
"""Path to the Python interpreter inside *venv_dir* (may not exist)."""
bin_dir = venv_bin_dir(venv_dir, windows=windows)
Expand Down
8 changes: 7 additions & 1 deletion tests/hermes_cli/test_lazy_refresh_venv_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ def fake_restore(dependencies, prefix, *, env=None):
monkeypatch.setattr(m.subprocess, "run", fake_run)
monkeypatch.setattr(managed_uv, "update_managed_uv", lambda **kwargs: None)
monkeypatch.setattr(managed_uv, "ensure_uv", lambda **kwargs: "uv")
# No in-tree venv exists under the tmp PROJECT_ROOT: the running
# interpreter's own venv root is the install target — a nonexistent
# PROJECT_ROOT/venv must never be fabricated (#116148).
monkeypatch.setattr(update_cmd, "running_venv_root", lambda: tmp_path / "runner-venv")

args = SimpleNamespace(
yes=True,
Expand All @@ -247,10 +251,12 @@ def fake_restore(dependencies, prefix, *, env=None):
# The repair env is now built via managed_python_env (#83914): third-party
# UV vars are stripped, managed pins set, then VIRTUAL_ENV re-pointed at
# the install's venv. Assert the CONTRACT, not the raw environ copy.
# running_venv_root was pinned above: the expected target is that venv,
# not a fabricated PROJECT_ROOT/venv (#116148).
from hermes_cli.managed_uv import managed_python_env

expected_env = managed_python_env()
expected_env["VIRTUAL_ENV"] = str(tmp_path / "venv")
expected_env["VIRTUAL_ENV"] = str(tmp_path / "runner-venv")
assert refresh_calls == [
(
["uv", "pip"],
Expand Down
82 changes: 82 additions & 0 deletions tests/hermes_cli/test_update_out_of_tree_venv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Out-of-tree installs: the update paths never fabricate a nonexistent VIRTUAL_ENV.

On installs whose interpreter lives outside the checkout (``$HERMES_HOME\\venvs\\hermes`` — the
layout the shipped Windows gateway launchers pin themselves), ``PROJECT_ROOT/venv`` does not
exist. Pointing ``uv`` there aborts every command with ``Failed to inspect Python interpreter``
before any work: the ``hermes tools`` import probe resolves no target, tool-dependency restores
and lazy refreshes fail, and the update still reports success. See #116148.
"""

import sys
from pathlib import Path

import pytest

import hermes_cli.update_cmd as update_cmd
from hermes_constants import running_venv_root


@pytest.fixture
def fake_venv(tmp_path: Path) -> Path:
"""A venv root with the layout ``running_venv_root`` detects (bin/ + pyvenv.cfg)."""
root = tmp_path / "venvs" / "hermes"
bin_dir = root / ("Scripts" if sys.platform == "win32" else "bin")
bin_dir.mkdir(parents=True)
(bin_dir / ("python.exe" if sys.platform == "win32" else "python")).touch()
(root / "pyvenv.cfg").write_text("home = /usr/bin\n", encoding="utf-8")
return root


def _venv_python(root: Path) -> Path:
return root / ("Scripts" if sys.platform == "win32" else "bin") / (
"python.exe" if sys.platform == "win32" else "python")


class TestRunningVenvRoot:
def test_returns_root_for_a_real_venv_interpreter(self, monkeypatch, fake_venv: Path):
monkeypatch.setattr(sys, "executable", str(_venv_python(fake_venv)))
assert running_venv_root() == fake_venv

def test_none_for_a_system_interpreter_shape(self, monkeypatch, tmp_path: Path):
# /usr/bin/python has the same parent-dir shape as a venv bin dir; without
# pyvenv.cfg it must NOT read as a venv root.
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
exe = bin_dir / "python"
exe.touch()
monkeypatch.setattr(sys, "executable", str(exe))
assert running_venv_root() is None


class TestResolvedInstallVenvDir:
def test_out_of_tree_install_falls_back_to_running_interpreters_venv(self, monkeypatch, fake_venv: Path):
monkeypatch.setattr(update_cmd, "project_venv_dir", lambda _root: None)
monkeypatch.setattr(update_cmd, "running_venv_root", lambda: fake_venv)
assert update_cmd._resolved_install_venv_dir() == fake_venv

def test_no_resolvable_venv_returns_none_not_a_fabricated_path(self, monkeypatch):
monkeypatch.setattr(update_cmd, "project_venv_dir", lambda _root: None)
monkeypatch.setattr(update_cmd, "running_venv_root", lambda: None)
assert update_cmd._resolved_install_venv_dir() is None

def test_in_tree_venv_wins_over_the_running_interpreters_venv(self, monkeypatch, tmp_path: Path):
in_tree = tmp_path / "venv"
in_tree.mkdir()
monkeypatch.setattr(update_cmd, "project_venv_dir", lambda _root: in_tree)
monkeypatch.setattr(update_cmd, "running_venv_root", lambda: tmp_path / "elsewhere")
assert update_cmd._resolved_install_venv_dir() == in_tree


class TestPipInstallPrefix:
def test_env_pins_the_resolved_venv(self, monkeypatch, fake_venv: Path):
monkeypatch.setattr(update_cmd, "project_venv_dir", lambda _root: None)
monkeypatch.setattr(update_cmd, "running_venv_root", lambda: fake_venv)
prefix, env = update_cmd._pip_install_prefix("/fake/bin/uv")
assert prefix == ["/fake/bin/uv", "pip"]
assert env is not None and env["VIRTUAL_ENV"] == str(fake_venv)

def test_env_leaves_virtual_env_unset_when_nothing_resolves(self, monkeypatch):
monkeypatch.setattr(update_cmd, "project_venv_dir", lambda _root: None)
monkeypatch.setattr(update_cmd, "running_venv_root", lambda: None)
_prefix, env = update_cmd._pip_install_prefix("/fake/bin/uv")
assert env is not None and "VIRTUAL_ENV" not in env