fix(pm): never adopt another install's venv for a checkout
project_venv_dir() fell back to the running interpreter's venv whenever
hermes_constants was loaded from the checkout. Where the code was loaded
from says nothing about who owns the interpreter: with
`PYTHONPATH=<dev checkout> <app install>/venv/bin/python -m hermes_cli.main`
(a shell wrapper around a dev tree), PROJECT_ROOT is the dev checkout but
the venv is the Desktop install's. base_venv() then returned the app's
venv, `hermes update` synced the dev tree into it, and the Desktop
install's venv became an editable install of the dev checkout. From then
on the Desktop shell tracked ~/.hermes/hermes-agent while its backend and
its handed-off `hermes update` ran and pulled the dev tree, so every
in-app update "succeeded" without moving the install. The same misread
let running_from_selected_environment() accept lazy extras into that
venv.
Only fall back to the running venv when its own hermes-agent install
records this checkout in direct_url.json, which every install of a
checkout into a venv writes (installers, uv sync). Otherwise the
checkout gets its own environment, as it did before 4f6c04cd07. The
#116148 out-of-tree layout (a venv installed from the checkout) still
resolves to the running interpreter.
This commit is contained in:
@@ -1307,11 +1307,39 @@ def project_venv_dir(project_root) -> Path | None:
|
||||
running = Path(sys.prefix)
|
||||
if (Path(__file__).resolve().parent == root.resolve()
|
||||
and sys.prefix != sys.base_prefix
|
||||
and venv_python_path(running).is_file()):
|
||||
and venv_python_path(running).is_file()
|
||||
and _venv_installs_checkout(running, root)):
|
||||
return running
|
||||
return None
|
||||
|
||||
|
||||
def _venv_installs_checkout(venv: Path, root: Path) -> bool:
|
||||
"""Is *venv*'s own ``hermes-agent`` installed from *root*?
|
||||
|
||||
Where this module was loaded from does not answer that: ``PYTHONPATH=<checkout>
|
||||
<other install>/bin/python`` runs one checkout's code on another install's interpreter,
|
||||
and adopting that venv made a dev checkout's update rewrite the Desktop install's venv
|
||||
into an editable install of the dev tree. Every install of a checkout into a venv
|
||||
(installers, ``uv sync``) records the source tree in ``direct_url.json``.
|
||||
"""
|
||||
import json
|
||||
from importlib.metadata import distributions
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from pm.environments import site_packages
|
||||
|
||||
for dist in distributions(name="hermes-agent", path=[str(site_packages(venv))]):
|
||||
try:
|
||||
raw = dist.read_text("direct_url.json") # windows-footgun: ok — importlib.metadata API, reads utf-8, no encoding=
|
||||
url = json.loads(raw or "{}").get("url", "")
|
||||
except ValueError:
|
||||
continue
|
||||
if url.startswith("file:") and Path(url2pathname(urlparse(url).path)).resolve() == root.resolve():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
|
||||
"""Frozen updater surface: pre-PM updaters import this name; pm.environments owns it."""
|
||||
from pm.environments import venv_python
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for hermes_constants module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -616,23 +617,46 @@ class TestProjectVenvDirOutOfTree:
|
||||
monkeypatch.setattr(sys, "prefix", str(venv))
|
||||
monkeypatch.setattr(sys, "base_prefix", str(checkout / "no-such-base"))
|
||||
|
||||
@staticmethod
|
||||
def _venv_installed_from(venv, source):
|
||||
from pm.environments import site_packages
|
||||
hermes_constants.venv_python_path(venv).parent.mkdir(parents=True)
|
||||
hermes_constants.venv_python_path(venv).write_text("", encoding="utf-8")
|
||||
dist_info = site_packages(venv) / "hermes_agent-0.0.0.dist-info"
|
||||
dist_info.mkdir(parents=True)
|
||||
(dist_info / "METADATA").write_text("Name: hermes-agent\nVersion: 0.0.0\n", encoding="utf-8")
|
||||
(dist_info / "direct_url.json").write_text(
|
||||
json.dumps({"url": source.resolve().as_uri(), "dir_info": {"editable": True}}), encoding="utf-8")
|
||||
|
||||
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._venv_installed_from(venv, checkout)
|
||||
self._running_from(monkeypatch, checkout, venv)
|
||||
|
||||
assert hermes_constants.project_venv_dir(checkout) == venv
|
||||
|
||||
def test_another_installs_interpreter_is_never_claimed(self, monkeypatch, tmp_path):
|
||||
"""``PYTHONPATH=<dev checkout> <app venv>/bin/python``: the code comes from the dev checkout,
|
||||
but the venv belongs to the app install. Claiming it pointed the dev checkout's update sync at
|
||||
the app's venv, which became an editable install of the dev tree."""
|
||||
dev = tmp_path / "dev" / "hermes-agent"
|
||||
dev.mkdir(parents=True)
|
||||
app = tmp_path / "app" / "hermes-agent"
|
||||
app.mkdir(parents=True)
|
||||
venv = tmp_path / "app" / "venv"
|
||||
self._venv_installed_from(venv, app)
|
||||
self._running_from(monkeypatch, dev, venv)
|
||||
|
||||
assert hermes_constants.project_venv_dir(dev) is None
|
||||
|
||||
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._venv_installed_from(venv, checkout)
|
||||
self._running_from(monkeypatch, checkout, venv)
|
||||
other = tmp_path / "not-our-checkout"
|
||||
other.mkdir()
|
||||
|
||||
Reference in New Issue
Block a user