fix: refuse venv replacement when a secondary profile is unreadable
This commit is contained in:
@@ -398,6 +398,11 @@ class Venv(StatePackage):
|
||||
candidate = generation / "venv"
|
||||
environment = managed_environment(candidate, env=source_build_environment(project),
|
||||
explicit=explicit or repair, output=sys.stderr)
|
||||
if not repair:
|
||||
# Inspection may skip a broken secondary profile, but publishing a replacement
|
||||
# graph must not silently evict its recorded members (including passed candidates).
|
||||
from pm.plugins_state import enabled_plugins_ordered
|
||||
enabled_plugins_ordered()
|
||||
members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs)
|
||||
try:
|
||||
generation.mkdir(parents=True)
|
||||
|
||||
@@ -268,6 +268,69 @@ def test_conflicting_candidate_refused_unenabled_and_unimported(admission_env):
|
||||
subprocess.run([str(sidecar_python), "-c", "import sys; assert sys.prefix != sys.base_prefix"], check=True, timeout=30)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
|
||||
def test_malformed_secondary_cannot_evict_recorded_member(admission_env, monkeypatch, caplog):
|
||||
"""A→B→A: passive inspection survives bad config; A's recorded graph does not shrink."""
|
||||
from pm.environments import runtime_facts_path, selected_venv
|
||||
from pm.install import sync_venv, venv_is_current
|
||||
from pm.lock import Facts
|
||||
|
||||
tmp_path, home_a = admission_env
|
||||
core = tmp_path / "core"
|
||||
profile = home_a / "profiles" / "work"
|
||||
profile.mkdir(parents=True)
|
||||
_write_enabled(profile, ["profile-dep"])
|
||||
member = profile / "plugins" / "profile-dep"
|
||||
member.mkdir(parents=True)
|
||||
(member / "pyproject.toml").write_text(
|
||||
'[project]\nname="profile-dep"\nversion="1"\nrequires-python=">=3.11"\n'
|
||||
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
|
||||
)
|
||||
sync_venv(explicit=True)
|
||||
recorded = Facts(runtime_facts_path(core), strict=True).get("venv")
|
||||
selected = selected_venv(core)
|
||||
assert recorded["stamp"] and selected.is_dir()
|
||||
assert "profile-dep" in (Path(recorded["resolved_lock"]).parent / "pyproject.toml").read_text()
|
||||
|
||||
bad = profile / "config.yaml"
|
||||
bad.write_text("plugins: [broken]\n", encoding="utf-8")
|
||||
candidate = home_a / "plugins" / "new-dep"
|
||||
candidate.mkdir(parents=True)
|
||||
(candidate / "pyproject.toml").write_text(
|
||||
'[project]\nname="new-dep"\nversion="1"\nrequires-python=">=3.11"\n'
|
||||
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
|
||||
)
|
||||
_write_enabled(home_a, ["new-dep"])
|
||||
assert venv_is_current(project_root=core) is False
|
||||
assert str(bad) in caplog.text
|
||||
with pytest.raises(ValueError, match="config.yaml"):
|
||||
sync_venv(explicit=True)
|
||||
assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded
|
||||
assert selected_venv(core) == selected
|
||||
# Even a precomputed member list cannot bypass a newly broken profile.
|
||||
with pytest.raises(ValueError, match="config.yaml"):
|
||||
sync_venv(explicit=True, plugin_dirs=[])
|
||||
assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded
|
||||
|
||||
home_b = tmp_path / "home-b"
|
||||
_write_enabled(home_b, [])
|
||||
monkeypatch.setenv("HERMES_HOME", str(home_b))
|
||||
sync_venv(explicit=True)
|
||||
assert selected_venv(core).is_dir()
|
||||
assert Facts(runtime_facts_path(core), strict=True).get("venv")["stamp"] != recorded["stamp"]
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(home_a))
|
||||
with pytest.raises(ValueError, match="config.yaml"):
|
||||
sync_venv(explicit=True)
|
||||
assert selected_venv(core) == selected
|
||||
_write_enabled(profile, ["profile-dep"])
|
||||
sync_venv(explicit=True)
|
||||
restored = Facts(runtime_facts_path(core), strict=True).get("venv")
|
||||
assert selected_venv(core).is_dir()
|
||||
assert restored["stamp"] != recorded["stamp"]
|
||||
assert "profile-dep" in (Path(restored["resolved_lock"]).parent / "pyproject.toml").read_text()
|
||||
|
||||
|
||||
def test_active_context_home_exported_to_wrapper_subprocess(monkeypatch, tmp_path):
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from tools.environments.local import build_subprocess_env
|
||||
|
||||
@@ -213,11 +213,12 @@ def test_uncertain_profile_selection_skips_sync_but_not_admission_or_recorded_re
|
||||
)
|
||||
assert result.stdout.strip() == "1.0"
|
||||
assert Path(Facts(paths.runtime_facts_path()).get("venv")["resolved_lock"]).read_bytes() == old_lock
|
||||
engine.sync_venv(explicit=True)
|
||||
with pytest.raises(ValueError, match="config.yaml"):
|
||||
engine.sync_venv(explicit=True)
|
||||
assert str(sibling_config) in caplog.text
|
||||
current = selected_venv(core)
|
||||
assert selected_venv(core) == repaired
|
||||
result = subprocess.run(
|
||||
[str(current / ("Scripts/python.exe" if os.name == "nt" else "bin/python")),
|
||||
[str(repaired / ("Scripts/python.exe" if os.name == "nt" else "bin/python")),
|
||||
"-I", "-c", "import core_dep; print(core_dep.__version__)"],
|
||||
cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user