The runtime venv installs the core project editable against the PM
build snapshot (workspace/), so venv/bin/hermes always imports that
frozen copy. PM only rebuilds the venv on lock/extras/python-pin/plugin
changes, so a source-only update leaves the copy stale while the
gateway keeps running the checkout via activate_dependencies's
sys.path override. Any child that resolves `hermes` off PATH (terminal
tool, cron, kanban workers) still finds venv/bin first and runs the
stale snapshot instead — including `gateway install`/`restart`, which
then pins launchd/systemd to the snapshot's launcher path.
Prepend the checkout's own .hermes/bin ahead of venv/bin in
activate_dependencies so `hermes`/`hermes-acp` always resolve to the
launcher that puts the checkout first, regardless of what else lives
in the venv. This also fixes tools/environments/local.py's
_resolve_hermes_bin_dir(), whose shutil.which("hermes") now finds the
launcher dir instead of the venv's console script, so no separate guard
on PROJECT_ROOT in generate_launchd_plist / systemd unit generation is
needed: the snapshot's hermes_cli is never the running one.
Fixes #124627
222 lines
11 KiB
Python
222 lines
11 KiB
Python
"""Install-scoped dependency selection is readable before third-party imports."""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def test_install_runtime_selection_is_scoped_and_read_only(tmp_path, monkeypatch):
|
|
from pm import environments as runtime_paths
|
|
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
first, second = tmp_path / "first", tmp_path / "second"
|
|
for root in (first, second):
|
|
(root / ".venv").mkdir(parents=True)
|
|
assert runtime_paths.selected_venv(first) == first / ".venv"
|
|
assert not home.exists()
|
|
state = runtime_paths.install_state_dir(first)
|
|
assert state != runtime_paths.install_state_dir(second)
|
|
generation = state / "environments" / "candidate" / "venv"
|
|
generation.mkdir(parents=True)
|
|
(generation / "pyvenv.cfg").write_text("home = test\n")
|
|
(state / "facts.json").write_text(json.dumps({
|
|
"schema": 1, "packages": {"venv": {"environment": str(generation), "stamp": "verified"}},
|
|
}))
|
|
assert runtime_paths.selected_venv(first) == generation
|
|
assert runtime_paths.selected_venv(second) == second / ".venv"
|
|
monkeypatch.setenv("HERMES_HOME", str(home / "profiles" / "work"))
|
|
assert runtime_paths.selected_venv(first) == generation
|
|
|
|
|
|
def test_boot_uses_one_selected_dependency_tree_in_fresh_process(tmp_path, monkeypatch):
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pm import environments as runtime_paths
|
|
|
|
root = tmp_path / "repo"
|
|
base = root / "venv"
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
state = runtime_paths.install_state_dir(root)
|
|
selected = state / "environments" / "new" / "venv"
|
|
def site_of(venv):
|
|
return venv / ("Lib/site-packages" if os.name == "nt" else f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
|
|
for venv, version in [(base, "old"), (selected, "new")]:
|
|
site = site_of(venv)
|
|
site.mkdir(parents=True)
|
|
(venv / "pyvenv.cfg").write_text("home = test")
|
|
(site / "probe_package.py").write_text(f"version = {version!r}")
|
|
(site_of(base) / "base_only.py").write_text("version = 'must-not-leak'")
|
|
(state / "facts.json").write_text(json.dumps({"schema": 1, "packages": {
|
|
"venv": {"environment": str(selected)}
|
|
}}))
|
|
code = (
|
|
"import sys; from pathlib import Path; from pm.environments import activate_dependencies; "
|
|
"sys.path.insert(0, sys.argv[2]); activate_dependencies(Path(sys.argv[1])); "
|
|
"import probe_package, importlib.util; print(probe_package.version); "
|
|
"print(importlib.util.find_spec('base_only') is None)"
|
|
)
|
|
process = subprocess.run([sys.executable, "-c", code, str(root), str(site_of(base))],
|
|
env=dict(os.environ), text=True, capture_output=True, timeout=30)
|
|
assert process.returncode == 0, process.stderr
|
|
assert process.stdout.splitlines() == ["new", "True"]
|
|
|
|
|
|
def test_boot_puts_the_checkout_launcher_ahead_of_the_venvs_own_console_script(tmp_path, monkeypatch):
|
|
"""#124627: the venv's `hermes` console script is an editable install bound to the
|
|
build-time source snapshot. A child resolving `hermes` off PATH must reach the
|
|
checkout's own launcher, never that stale copy."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pm import environments as runtime_paths
|
|
|
|
root = tmp_path / "repo"
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
state = runtime_paths.install_state_dir(root)
|
|
selected = state / "environments" / "new" / "venv"
|
|
def site_of(venv):
|
|
return venv / ("Lib/site-packages" if os.name == "nt" else f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
|
|
site_of(selected).mkdir(parents=True)
|
|
(selected / "pyvenv.cfg").write_text("home = test")
|
|
venv_bin = runtime_paths.venv_bin_dir(selected)
|
|
venv_bin.mkdir(parents=True)
|
|
checkout_bin = root / ".hermes" / "bin"
|
|
checkout_bin.mkdir(parents=True)
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
(state / "facts.json").write_text(json.dumps({"schema": 1, "packages": {
|
|
"venv": {"environment": str(selected)}
|
|
}}))
|
|
code = (
|
|
"import os, sys; from pathlib import Path; from pm.environments import activate_dependencies; "
|
|
"activate_dependencies(Path(sys.argv[1])); print(os.environ.get('PATH', ''))"
|
|
)
|
|
process = subprocess.run([sys.executable, "-c", code, str(root)],
|
|
env=dict(os.environ), text=True, capture_output=True, timeout=30)
|
|
assert process.returncode == 0, process.stderr
|
|
entries = process.stdout.strip().split(os.pathsep)
|
|
assert entries.index(str(checkout_bin)) < entries.index(str(venv_bin))
|
|
|
|
|
|
@pytest.mark.parametrize("command,allowed", [(["pm", "install", "--help"], True), (["pm", "doctor"], True),
|
|
(["-p", "default", "pm", "repair"], True), (["chat"], False), (["chat", "pm", "install"], False)])
|
|
def test_broken_environment_keeps_explicit_repair_entry_reachable(tmp_path, monkeypatch, command, allowed):
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pm.environments import runtime_facts_path
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
repo = Path(__file__).resolve().parents[2]
|
|
record = runtime_facts_path(repo)
|
|
record.parent.mkdir(parents=True)
|
|
record.write_text(json.dumps({"packages": {"venv": {"environment": str(tmp_path / "missing")}}}))
|
|
code = "import sys; sys.argv = ['hermes', *sys.argv[1:]]; import hermes_bootstrap; print('bootstrap-ready')"
|
|
result = subprocess.run([sys.executable, "-c", code, *command], env=dict(os.environ),
|
|
capture_output=True, text=True, timeout=30)
|
|
assert (result.returncode == 0) is allowed, result.stderr
|
|
if not allowed:
|
|
assert "hermes pm repair" in result.stderr
|
|
assert "Traceback" not in result.stderr
|
|
|
|
|
|
def test_manual_repair_bypasses_damaged_generation_activation(tmp_path, monkeypatch):
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pm.environments import install_state_dir, runtime_facts_path, site_packages
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
generation = install_state_dir(repo) / "environments" / "damaged"
|
|
environment = generation / "venv"
|
|
site_packages(environment).mkdir(parents=True)
|
|
(environment / "pyvenv.cfg").write_text("home = test", encoding="utf-8")
|
|
(generation / ".lease-managed").touch()
|
|
(generation / ".leases").write_text("not a directory", encoding="utf-8")
|
|
runtime_facts_path(repo).write_text(json.dumps({"schema": 1, "packages": {"venv": {
|
|
"environment": str(environment), "extras": [], "stamp": "old",
|
|
}}}), encoding="utf-8")
|
|
env = {**os.environ, "PYTHONPATH": str(repo)}
|
|
result = subprocess.run([sys.executable, "-S", "-m", "hermes_cli.main", "pm", "repair", "--help"],
|
|
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "hermes pm repair" in result.stdout
|
|
|
|
|
|
@pytest.mark.parametrize("interpreter", ["store", "venv"])
|
|
@pytest.mark.parametrize("with_state", [True, False])
|
|
def test_boot_never_activates_the_pre_pm_venv(tmp_path, monkeypatch, interpreter, with_state):
|
|
"""Nothing committed must not mean "load the in-tree venv": it was built for another
|
|
interpreter, so PM's store Python lost every compiled module from it after an update."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pm import environments as runtime_paths
|
|
|
|
base_python = getattr(sys, "_base_executable", sys.executable)
|
|
base_prefix = Path(sys.base_prefix).resolve()
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
# The store interpreter is PM's: a non-venv Python living under the runtime dir.
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(base_prefix.parent))
|
|
root = tmp_path / "repo"
|
|
legacy = root / "venv"
|
|
(legacy / "pyvenv.cfg").parent.mkdir(parents=True)
|
|
(legacy / "pyvenv.cfg").write_text("home = test\n")
|
|
runtime_paths.site_packages(legacy).mkdir(parents=True)
|
|
(runtime_paths.site_packages(legacy) / "legacy_only.py").write_text("")
|
|
if with_state:
|
|
runtime_paths.install_state_dir(root).mkdir(parents=True)
|
|
python = base_python
|
|
if interpreter == "venv":
|
|
subprocess.run([base_python, "-m", "venv", "--without-pip", str(tmp_path / "dev")], check=True, timeout=60)
|
|
python = str(runtime_paths.venv_python(tmp_path / "dev"))
|
|
repo = Path(__file__).resolve().parents[2]
|
|
code = (
|
|
"import sys, importlib.util; from pathlib import Path; sys.path.insert(0, sys.argv[1]); "
|
|
"from pm.environments import activate_dependencies\n"
|
|
"try:\n activate_dependencies(Path(sys.argv[2]))\n"
|
|
"except RuntimeError as exc:\n print('refused:', exc); raise SystemExit(0)\n"
|
|
"print('legacy importable:', importlib.util.find_spec('legacy_only') is not None)"
|
|
)
|
|
result = subprocess.run([python, "-I", "-c", code, str(repo), str(root)], env=dict(os.environ),
|
|
capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
expected = ("refused: no dependency environment is committed" if interpreter == "store"
|
|
else "legacy importable: False")
|
|
assert result.stdout.strip().startswith(expected), result.stdout
|
|
|
|
|
|
@pytest.mark.parametrize("data", [[], {"packages": []}, {"packages": {"venv": []}}])
|
|
def test_malformed_selection_has_actionable_error(tmp_path, monkeypatch, data):
|
|
from pm import environments as runtime_paths
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
record = runtime_paths.runtime_facts_path(tmp_path / "repo")
|
|
record.parent.mkdir(parents=True)
|
|
record.write_text(json.dumps(data))
|
|
with pytest.raises(RuntimeError, match="dependency environment"):
|
|
runtime_paths.selected_venv(tmp_path / "repo")
|
|
|
|
|
|
@pytest.mark.parametrize("bad_path", ["outside", "missing"])
|
|
def test_invalid_selected_environment_never_silently_falls_back(tmp_path, monkeypatch, bad_path):
|
|
from pm import environments as runtime_paths
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
root = tmp_path / "repo"
|
|
(root / "venv").mkdir(parents=True)
|
|
state = runtime_paths.install_state_dir(root)
|
|
state.mkdir(parents=True)
|
|
candidate = tmp_path / "outside" if bad_path == "outside" else state / "environments" / "missing"
|
|
if bad_path == "outside":
|
|
candidate.mkdir()
|
|
(candidate / "pyvenv.cfg").write_text("home = test\n")
|
|
(state / "facts.json").write_text(json.dumps({
|
|
"schema": 1, "packages": {"venv": {"environment": str(candidate)}},
|
|
}))
|
|
with pytest.raises(RuntimeError, match="environment"):
|
|
runtime_paths.selected_venv(root)
|