fix(pm): an unreadable secondary profile config cannot fail an update
Venv.apply refused the whole graph when any secondary profile's config.yaml was unreadable, so one broken sibling config failed every update. Update syncs now leave that profile's plugins out of the union, report it (stderr + receipt warning), and build the rest; the profile's plugins rejoin on the next sync once its config is fixed. Boot currency already skips broken secondaries, so the result reads as current. Ordinary syncs keep refusing to shrink the recorded graph.
This commit is contained in:
@@ -656,13 +656,14 @@ def _target_selection(package, fact: dict, *, extras, inputs: dict, repair: bool
|
||||
|
||||
|
||||
def _commit_selection(package, facts: Facts, change, *, enabled: list[str], stamp: str, inputs: dict,
|
||||
current: bool, repair: bool, explicit: bool) -> None:
|
||||
current: bool, repair: bool, explicit: bool, skip_invalid_secondary: bool = False) -> None:
|
||||
"""Build (unless current), publish the plugin change, then record the selection."""
|
||||
from pm import receipt
|
||||
from hermes_cli.runtime_state import finish_publication, recover_publication
|
||||
|
||||
try:
|
||||
result = {} if current else (package.apply(enabled, explicit=explicit, **inputs) or {})
|
||||
result = {} if current else (package.apply(enabled, explicit=explicit,
|
||||
skip_invalid_secondary=skip_invalid_secondary, **inputs) or {})
|
||||
if not repair and package.expected_stamp(enabled, **inputs) != stamp:
|
||||
raise ValueError("Dependency inputs changed while preparing publication; retry.")
|
||||
if change is not None:
|
||||
|
||||
@@ -385,8 +385,13 @@ class Venv(StatePackage):
|
||||
h.update(members_stamp(enabled_member_dirs() if plugin_dirs is None else plugin_dirs).encode())
|
||||
return h.hexdigest()
|
||||
|
||||
def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False) -> dict:
|
||||
"""Prepare one complete environment; the caller commits its selection."""
|
||||
def apply(self, extras: list[str], *, plugin_dirs=None, repair: bool = False, explicit: bool = False,
|
||||
skip_invalid_secondary: bool = False) -> dict:
|
||||
"""Prepare one complete environment; the caller commits its selection.
|
||||
|
||||
``skip_invalid_secondary`` is the update's contract: an unreadable secondary profile
|
||||
is left out (the caller reports it) instead of refusing the whole graph.
|
||||
"""
|
||||
import uuid
|
||||
from pm.environments import install_state_dir, runtime_facts_path
|
||||
from pm.environment import managed_environment
|
||||
@@ -402,8 +407,9 @@ class Venv(StatePackage):
|
||||
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).
|
||||
# An update does evict them, loudly: it must not fail on another profile's config.
|
||||
from pm.plugins_state import enabled_plugins_ordered
|
||||
enabled_plugins_ordered()
|
||||
enabled_plugins_ordered(skip_invalid_secondary=skip_invalid_secondary)
|
||||
members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs)
|
||||
try:
|
||||
generation.mkdir(parents=True)
|
||||
|
||||
@@ -5,7 +5,9 @@ choose again. An update has nobody to ask and must never fail because of a plugi
|
||||
core moved (a newer Python, a bumped pin, a newer manifest contract) under a plugin
|
||||
that was admitted against the old core. Such a plugin is disabled in every home that
|
||||
enables it, the reason reaches the operator and the receipt, and the update continues
|
||||
with the rest. Only a core that cannot build on its own still fails.
|
||||
with the rest. A secondary profile whose config cannot be read is left out of the union
|
||||
the same way (there is nothing to edit in it) until its config is fixed. Only a core that
|
||||
cannot build on its own still fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -117,8 +119,15 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
|
||||
"""
|
||||
from pm import receipt
|
||||
from pm.install import _commit_selection, _runtime_state_matches, _target_selection
|
||||
from pm.plugins_state import dependency_homes, read_home_selection
|
||||
from pm.workspace import _is_member_candidate, enabled_plugin_entries
|
||||
|
||||
notices: list[str] = []
|
||||
for home in dependency_homes()[1:]:
|
||||
try:
|
||||
read_home_selection(home)
|
||||
except ValueError as exc:
|
||||
notices.append(f"Skipped the plugins of profile {home}: {exc}; they rejoin once its config.yaml is fixed")
|
||||
entries = enabled_plugin_entries(skip_invalid_secondary=True)
|
||||
reasons = static_reasons(entries, _interpreter_version())
|
||||
|
||||
@@ -132,7 +141,8 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
|
||||
receipt.record_feature_list(enabled)
|
||||
_commit_selection(package, facts, PluginEviction(entries, reasons) if reasons else None,
|
||||
enabled=enabled, stamp=stamp, inputs=inputs,
|
||||
current=_runtime_state_matches(fact, stamp), repair=False, explicit=explicit)
|
||||
current=_runtime_state_matches(fact, stamp), repair=False, explicit=explicit,
|
||||
skip_invalid_secondary=True)
|
||||
|
||||
kept = members()
|
||||
try:
|
||||
@@ -143,21 +153,20 @@ def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explic
|
||||
enabled = _target_selection(package, fact, extras=extras, inputs={"plugin_dirs": []},
|
||||
repair=False, shipped=shipped, frozen=frozen)[0]
|
||||
try:
|
||||
package.apply(enabled, explicit=explicit, plugin_dirs=[])
|
||||
package.apply(enabled, explicit=explicit, plugin_dirs=[], skip_invalid_secondary=True)
|
||||
except InstallError:
|
||||
raise failure from None
|
||||
fitting: list[Path] = []
|
||||
for member in kept:
|
||||
try:
|
||||
package.apply(enabled, explicit=explicit, plugin_dirs=[*fitting, member])
|
||||
package.apply(enabled, explicit=explicit, plugin_dirs=[*fitting, member], skip_invalid_secondary=True)
|
||||
except InstallError as exc:
|
||||
reasons[member.resolve()] = f"the dependency environment no longer builds with it: {exc.cause[-400:]}"
|
||||
else:
|
||||
fitting.append(member)
|
||||
commit()
|
||||
for plugins_dir, name, plugin_dir in entries:
|
||||
reason = reasons.get(plugin_dir.resolve())
|
||||
if reason:
|
||||
message = f"Disabled plugin '{name}' in {plugins_dir.parent}: {reason}"
|
||||
print(f"⚠ {message}", file=sys.stderr, flush=True)
|
||||
receipt.record_warning(message)
|
||||
notices += [f"Disabled plugin '{name}' in {plugins_dir.parent}: {reasons[plugin_dir.resolve()]}"
|
||||
for plugins_dir, name, plugin_dir in entries if plugin_dir.resolve() in reasons]
|
||||
for message in notices:
|
||||
print(f"⚠ {message}", file=sys.stderr, flush=True)
|
||||
receipt.record_warning(message)
|
||||
|
||||
@@ -392,6 +392,39 @@ def test_update_sync_disables_later_plugin_of_unresolvable_union(admission_env):
|
||||
assert venv_is_current(project_root=tmp_path / "core") is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
|
||||
def test_update_sync_survives_unreadable_secondary_profile(admission_env):
|
||||
"""A secondary profile's broken config.yaml cannot fail an update: its plugins sit out
|
||||
(reported), the rest build, and the next boot sees a current venv."""
|
||||
from pm.environments import runtime_facts_path
|
||||
from pm.install import sync_venv, venv_is_current
|
||||
from pm.lock import Facts
|
||||
|
||||
tmp_path, home = admission_env
|
||||
core = tmp_path / "core"
|
||||
member = home / "plugins" / "primary-dep"
|
||||
member.mkdir(parents=True)
|
||||
(member / "pyproject.toml").write_text(
|
||||
'[project]\nname="primary-dep"\nversion="1"\nrequires-python=">=3.11"\n'
|
||||
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
|
||||
)
|
||||
_write_enabled(home, ["primary-dep"])
|
||||
broken = home / "profiles" / "work" / "config.yaml"
|
||||
broken.parent.mkdir(parents=True)
|
||||
broken.write_text("plugins: [broken]\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="config.yaml"):
|
||||
sync_venv(explicit=True) # an ordinary sync still refuses to shrink the graph
|
||||
|
||||
sync_venv(explicit=True, evict_incompatible_plugins=True)
|
||||
|
||||
workspace = Path(Facts(runtime_facts_path(core), strict=True).get("venv")["resolved_lock"]).parent
|
||||
assert "primary-dep" in (workspace / "pyproject.toml").read_text()
|
||||
assert broken.read_text(encoding="utf-8") == "plugins: [broken]\n"
|
||||
assert str(broken.parent) in json.dumps(_latest_receipt(home).get("warnings"))
|
||||
assert venv_is_current(project_root=core) is True
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user