diff --git a/pm/client.py b/pm/client.py index fb5fbf957d..eff4735da5 100644 --- a/pm/client.py +++ b/pm/client.py @@ -5,6 +5,7 @@ from collections.abc import Mapping, Sequence import json from pathlib import Path import subprocess +import sys import threading import uuid @@ -183,6 +184,20 @@ def ensure(name, *, base_env=None, explicit=False, progress=None, pause_event=No def sync_venv(extras=None, *, explicit=False, plugin_dirs=None, extra_plugin_dirs=(), selection=None, staged_plugin=None, repair=False, project_root: Path | None = None) -> None: + from pm.environments import running_from_selected_environment + + if extras and not explicit and not repair and not running_from_selected_environment( + paths.repo_root() if project_root is None else Path(project_root)): + # A lazy extra may only extend the environment this process runs from. From any other + # interpreter (a build_environment test venv, a developer venv, a Nix Python) the sync would + # commit a selection this process never activates while every process booted afterwards + # swaps onto it — a generation without whatever the foreign interpreter carried. + from pm.install import _refuse_lazy + raise _refuse_lazy( + "venv", + f"{list(extras)}: this process is not running from the install's dependency environment " + f"({sys.prefix}); only an explicit install may change what later processes boot into", + ) if selection is not None and "expected_config" not in selection: from hermes_cli.runtime_state import _digest selection = {**selection, "expected_config": _digest(Path(selection["home"]) / "config.yaml") or "missing"} diff --git a/pm/environments.py b/pm/environments.py index 892f2c3dc7..8cc5510020 100644 --- a/pm/environments.py +++ b/pm/environments.py @@ -169,6 +169,27 @@ def site_packages(venv: Path) -> Path: return venv / f"lib/python{version[0]}.{version[1]}/site-packages" +def running_from_selected_environment(project_root: Path) -> bool: + """Does this process run on the environment PM selected for the install (base venv or committed + generation)? + + A lazy sync from any other interpreter — a build_environment test venv, a developer's own venv, + a Nix store Python — must not commit the install's selection: activation is a boot decision, so + this process keeps running unchanged while every process booted afterwards swaps onto a + generation that lacks whatever the foreign interpreter carried. + + activate_dependencies puts the selection's site-packages on sys.path without changing + sys.prefix, so sys.path is the signal (the same one ensure_import reads after a sync). + """ + import sys + + try: + selected = site_packages(selected_venv(project_root)).resolve() + except (OSError, RuntimeError, ValueError): + return False + return any(Path(entry).resolve() == selected for entry in sys.path if entry) + + def activate_dependencies(project_root: Path) -> None: """Select the committed tree at process boot, before third-party imports. diff --git a/tests/pm/test_features.py b/tests/pm/test_features.py index e790c13494..d6cfaed603 100644 --- a/tests/pm/test_features.py +++ b/tests/pm/test_features.py @@ -88,3 +88,38 @@ def test_sync_venv_allows_frozen_extras_when_lazy_off(rooted, monkeypatch): 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()