Generations are immutable and selection is a boot decision, so a mid-process sync never changes the running process — it changes what every process booted AFTERWARDS activates. That is correct when the caller runs on the selected environment (the new generation is that environment plus the extra). From any other interpreter (a build_environment test venv, a developer venv, a Nix store Python) the same sync commits a selection the caller never activates while later processes swap onto a generation missing whatever the foreign interpreter carried. CI hit exactly this: bedrock/vertex adapters synced [bedrock] at import from the test venv, the checkout gained its first selection, and every later per-file subprocess booted into a generation without the test group. pm.client.sync_venv now refuses a non-explicit extras sync unless the selected environment's site-packages is on sys.path (pm.environments. running_from_selected_environment). Explicit installs, repairs and plugin member syncs are unchanged; the refusal names `hermes pm install` as the remedy and is recorded on the receipt like every other lazy-install refusal.
126 lines
5.2 KiB
Python
126 lines
5.2 KiB
Python
"""pm.features: the frozen bundle feature set (enabled-features.json).
|
|
|
|
Lazy installs OFF = the bundle's feature list is FROZEN to the file the
|
|
bundle wrote (the EXACT extras that installed on that target); pm sync
|
|
never deviates and never installs a plugin member.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import pm.features as feats
|
|
|
|
|
|
@pytest.fixture
|
|
def rooted(tmp_path, monkeypatch):
|
|
"""Point features_path at a temp runtime dir (store_root().parent)."""
|
|
store = tmp_path / "tools"
|
|
store.mkdir()
|
|
monkeypatch.setattr("pm.paths.store_root", lambda: store)
|
|
return tmp_path
|
|
|
|
|
|
def test_write_then_read_roundtrip(rooted):
|
|
assert feats.read_features() is None
|
|
path = feats.write_features(["web", "acp", "web"])
|
|
assert path.is_file()
|
|
got = feats.read_features()
|
|
assert got == ["acp", "web"] # sorted, deduped
|
|
path.write_text("{ not json", encoding="utf-8")
|
|
assert feats.read_features() is None
|
|
|
|
|
|
|
|
|
|
def test_features_path_in_bundle_uses_payload_root(rooted):
|
|
payload = rooted / "payload"
|
|
payload.mkdir()
|
|
assert feats.features_path(payload) == payload / "enabled-features.json"
|
|
|
|
|
|
def test_sync_venv_refuses_outside_frozen_extras(rooted, monkeypatch):
|
|
feats.write_features(["web", "acp"])
|
|
|
|
|
|
import pm.install as ensure_mod
|
|
from pm.package import InstallError
|
|
|
|
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False)
|
|
with pytest.raises(InstallError) as exc:
|
|
ensure_mod.sync_venv(["slack"], explicit=True)
|
|
assert "frozen" in str(exc.value) or "outside" in str(exc.value)
|
|
from pm import receipt
|
|
saved = receipt.latest()
|
|
assert saved["refusal"]["code"] == "lazy-install"
|
|
assert saved["outcome"] == "failed" and saved["exit_code"] != 0
|
|
assert saved["steps"][-1]["ok"] is False
|
|
assert "slack" in saved["steps"][-1]["detail"]
|
|
assert "hermes pm install" in saved["steps"][-1]["detail"]
|
|
|
|
|
|
def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch):
|
|
feats.write_features(["web"])
|
|
|
|
from pm import paths
|
|
from pm.lock import Facts
|
|
from pm.environments import install_state_dir, runtime_facts_path
|
|
|
|
import pm.install as ensure_mod
|
|
|
|
# Matching stamp alone cannot certify a vanished environment. Reuse only
|
|
# the recorded selection while retaining the disabled acquisition policy.
|
|
repo = rooted / "repo"
|
|
repo.mkdir()
|
|
monkeypatch.setattr(paths, "repo_root", lambda: repo)
|
|
environment = install_state_dir(repo) / "environments" / "frozen" / "venv"
|
|
environment.mkdir(parents=True)
|
|
(environment / "pyvenv.cfg").write_text("home = fixture\n")
|
|
Facts(runtime_facts_path(repo)).record_state("venv", "stamp", ["web"], environment=environment)
|
|
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: False)
|
|
venv_pkg = ensure_mod.get_package("venv")
|
|
monkeypatch.setattr(
|
|
venv_pkg, "expected_stamp", lambda extras: "stamp"
|
|
)
|
|
monkeypatch.setattr(venv_pkg, "apply", lambda *args, **kwargs: pytest.fail("current frozen environment rebuilt"))
|
|
ensure_mod.sync_venv(["web"])
|
|
(environment / "pyvenv.cfg").unlink()
|
|
from pm.package import InstallError
|
|
with pytest.raises(InstallError, match="lazy installs are disabled"):
|
|
ensure_mod.sync_venv(["web"])
|
|
|
|
|
|
def test_lazy_sync_never_creates_the_first_selection_for_a_foreign_interpreter(rooted, monkeypatch):
|
|
"""A process running from an environment PM did not select (a build_environment test venv,
|
|
a dev venv, nix) imports an adapter whose extra is missing. Letting that lazy sync commit
|
|
the install's FIRST selection strands every later process: they boot into a generation
|
|
that lacks whatever the foreign interpreter carried (the CI "anthropic/aiohttp vanished"
|
|
class). Only an explicit install may create it. Exercised at the client seam every
|
|
ensure_import caller goes through, in the in-process (is_runtime) shape."""
|
|
import pm.client as client
|
|
import pm.install as ensure_mod
|
|
from pm import paths
|
|
from pm.package import InstallError
|
|
from pm.environments import runtime_facts_path
|
|
|
|
repo = rooted / "repo"
|
|
repo.mkdir()
|
|
(repo / "venv").mkdir() # the install's own base venv, which this pytest process is NOT running from
|
|
monkeypatch.setattr(paths, "repo_root", lambda: repo)
|
|
monkeypatch.setattr(client, "is_runtime", lambda: True)
|
|
monkeypatch.setattr(ensure_mod, "lazy_installs_allowed", lambda: True)
|
|
venv_pkg = ensure_mod.get_package("venv")
|
|
monkeypatch.setattr(venv_pkg, "expected_stamp", lambda extras, **kwargs: "stamp")
|
|
monkeypatch.setattr(
|
|
venv_pkg, "apply", lambda *args, **kwargs: pytest.fail("lazy sync built a generation for a foreign interpreter"))
|
|
|
|
assert not runtime_facts_path(repo).exists()
|
|
with pytest.raises(InstallError, match="not running from the install's dependency environment"):
|
|
client.sync_venv(["bedrock"])
|
|
assert not runtime_facts_path(repo).exists(), "a refused sync must not commit a selection"
|
|
|
|
# The remedy the refusal names still works: an explicit install creates the selection.
|
|
monkeypatch.setattr(venv_pkg, "apply", lambda *args, **kwargs: {})
|
|
client.sync_venv(["bedrock"], explicit=True)
|
|
assert runtime_facts_path(repo).is_file()
|