An update disabled any plugin that failed a trial build or its requires_hermes check. Both can be about us, not the plugin: an untagged source checkout reads as an older release (#122054), so requires_hermes misjudges a fine plugin, and a download failure says nothing about the plugin's code. Those now sit the plugin out of the build: config stays untouched and it rejoins once the cause clears. Disabling still happens on evidence about the plugin: requires-python vs the pinned interpreter, manifest_version, an invalid declaration, a uv resolution conflict, or its own build backend failing (new BuildFailure, keyed on uv's 'The build backend returned an error'). enabled_member_dirs now skips a requires_hermes misfit instead of raising. Boot's currency check raised on it before any sync could run, so the launch path never reached the update sync. The loader skips such a plugin anyway; admission still refuses enabling one.
199 lines
9.7 KiB
Python
199 lines
9.7 KiB
Python
"""Keep an update alive when an enabled plugin no longer fits the new core.
|
|
|
|
Admission refuses a plugin that does not fit, because the user is choosing and can
|
|
choose again. An update has nobody to ask and must never fail because of a plugin:
|
|
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.
|
|
|
|
Disabling needs evidence about the plugin itself: its requires-python against the pinned
|
|
interpreter, its manifest contract, a resolver proof, or its build failing. Anything that
|
|
might be about us or the moment instead (requires_hermes against a version identity that
|
|
can lag, a fetch or tooling failure) only sits the plugin out of this build: config is
|
|
untouched and it rejoins by itself. A secondary profile whose config cannot be read sits
|
|
out the same way until its config is fixed.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from pm.environment import BuildFailure, ResolutionConflict
|
|
from pm.environments import install_state_dir, runtime_facts_path
|
|
from pm.filesystem import durable_write_bytes, file_digest, read_bytes_or_none
|
|
from pm.package import InstallError
|
|
|
|
Entry = tuple[Path, str, Path] # (home plugins dir, selection key, plugin dir)
|
|
|
|
|
|
def _interpreter_version() -> str:
|
|
"""The Python the generation is built for; a plugin's requires-python is judged against it."""
|
|
from pm._uv import _toolchain
|
|
|
|
tools = _toolchain(explicit=True)
|
|
if tools is None:
|
|
raise InstallError("venv", "PM's pinned toolchain is unavailable")
|
|
probe = subprocess.run([str(tools[1]), "-I", "-c", "import platform; print(platform.python_version())"],
|
|
capture_output=True, text=True, check=True, timeout=60)
|
|
return probe.stdout.strip()
|
|
|
|
|
|
def static_verdicts(entries: list[Entry], python_version: str) -> tuple[dict[Path, str], dict[Path, str]]:
|
|
"""``(disable, sit out)`` reasons found without a resolver, keyed by resolved dir."""
|
|
from hermes_cli.plugins_manifest import requires_hermes_error
|
|
from pm.plugin_declarations import manifest_version_error, read_python_declaration
|
|
|
|
reasons: dict[Path, str] = {}
|
|
waiting: dict[Path, str] = {}
|
|
for _plugins_dir, _name, plugin_dir in entries:
|
|
key = plugin_dir.resolve()
|
|
if key in reasons or key in waiting:
|
|
continue
|
|
try:
|
|
declaration = read_python_declaration(plugin_dir)
|
|
except OSError as exc:
|
|
waiting[key] = f"its dependency declaration could not be read: {exc}"
|
|
continue
|
|
except (ValueError, TypeError) as exc:
|
|
reasons[key] = f"its dependency declaration is invalid: {exc}"
|
|
continue
|
|
# Mirrors enabled_member_dirs, so the recorded stamp is the one boot expects.
|
|
hermes = requires_hermes_error(declaration.manifest)
|
|
if hermes:
|
|
waiting[key] = hermes
|
|
continue
|
|
manifest = manifest_version_error(declaration.manifest, plugin_dir.name)
|
|
reason = (manifest.removeprefix(f"Plugin '{plugin_dir.name}' ") if manifest
|
|
else declaration.python_error(python_version))
|
|
if reason:
|
|
reasons[key] = reason
|
|
return reasons, waiting
|
|
|
|
|
|
class PluginEviction:
|
|
"""Config edits disabling the plugins in *reasons*; published like a plugin selection."""
|
|
|
|
def __init__(self, entries: list[Entry], reasons: dict[Path, str]):
|
|
from hermes_yaml import roundtrip_yaml
|
|
from pm.publication import selection_snapshot
|
|
|
|
self.configs = selection_snapshot()
|
|
by_home: dict[Path, list[str]] = {}
|
|
for plugins_dir, name, plugin_dir in entries:
|
|
if plugin_dir.resolve() in reasons:
|
|
by_home.setdefault(plugins_dir.parent, []).append(name)
|
|
self.edits: list[tuple[Path, bytes | None, bytes]] = []
|
|
for home, names in by_home.items():
|
|
path = home / "config.yaml"
|
|
previous = read_bytes_or_none(path)
|
|
yaml = roundtrip_yaml()
|
|
config = (yaml.load(previous.decode("utf-8-sig")) if previous else None) or {}
|
|
# read_home_selection already proved plugins/memory are mappings and the lists are lists.
|
|
plugins = config.get("plugins")
|
|
if plugins is None:
|
|
plugins = config["plugins"] = {}
|
|
disabled = plugins.get("disabled")
|
|
if disabled is None:
|
|
disabled = plugins["disabled"] = []
|
|
memory = config.get("memory")
|
|
for name in names:
|
|
if name not in disabled:
|
|
disabled.append(name)
|
|
# plugins.disabled does not veto memory.provider; the provider joins the union on its own.
|
|
if isinstance(memory, dict) and str(memory.get("provider") or "").strip() == name:
|
|
memory["provider"] = ""
|
|
output = io.StringIO()
|
|
yaml.dump(config, output)
|
|
self.edits.append((path, previous, output.getvalue().encode("utf-8")))
|
|
|
|
def publish(self, project: Path) -> None:
|
|
from pm.publication import selection_snapshot
|
|
|
|
if selection_snapshot() != self.configs:
|
|
raise ValueError("plugin configuration changed while preparing publication; retry")
|
|
row = {"configs": [{"config": str(path),
|
|
"previous": base64.b64encode(previous).decode() if previous is not None else None,
|
|
"config_after": hashlib.sha256(proposed).hexdigest()}
|
|
for path, previous, proposed in self.edits],
|
|
"facts_before": file_digest(runtime_facts_path(project))}
|
|
durable_write_bytes(install_state_dir(project) / "publication.json", json.dumps(row).encode())
|
|
for path, _previous, proposed in self.edits:
|
|
durable_write_bytes(path, proposed)
|
|
|
|
|
|
def sync_evicting(package, facts, fact: dict, *, extras, shipped, frozen, explicit: bool) -> None:
|
|
"""Build the discovered selection, disabling whatever plugin keeps it from building.
|
|
|
|
Static misfits go first (no resolver needed). If the rest still fails, core alone is
|
|
built to prove the plugins are the cause, then members are re-added in config order
|
|
and each one that breaks the build is disabled too.
|
|
"""
|
|
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, waiting = static_verdicts(entries, _interpreter_version())
|
|
|
|
def members() -> list[Path]:
|
|
return list(dict.fromkeys(plugin_dir for _plugins_dir, _name, plugin_dir in entries
|
|
if plugin_dir.resolve() not in reasons and plugin_dir.resolve() not in waiting
|
|
and _is_member_candidate(plugin_dir)))
|
|
|
|
def commit() -> None:
|
|
enabled, stamp, inputs = _target_selection(package, fact, extras=extras, inputs={"plugin_dirs": members()},
|
|
repair=False, shipped=shipped, frozen=frozen)
|
|
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,
|
|
skip_invalid_secondary=True)
|
|
|
|
kept = members()
|
|
try:
|
|
commit()
|
|
except InstallError as failure:
|
|
if not kept:
|
|
raise
|
|
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=[], 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], skip_invalid_secondary=True)
|
|
except (ResolutionConflict, BuildFailure) as exc:
|
|
reasons[member.resolve()] = f"the dependency environment no longer builds with it: {exc.cause[-400:]}"
|
|
except InstallError as exc:
|
|
# A fetch or tooling failure says nothing about the plugin: retry next sync.
|
|
waiting[member.resolve()] = f"its dependencies could not be prepared: {exc.cause[-400:]}"
|
|
else:
|
|
fitting.append(member)
|
|
commit()
|
|
for plugins_dir, name, plugin_dir in entries:
|
|
key = plugin_dir.resolve()
|
|
if key in reasons:
|
|
notices.append(f"Disabled plugin '{name}' in {plugins_dir.parent}: {reasons[key]}")
|
|
elif key in waiting:
|
|
notices.append(f"Left plugin '{name}' in {plugins_dir.parent} out of this update: {waiting[key]}; "
|
|
"it stays enabled and rejoins once that clears")
|
|
for message in notices:
|
|
print(f"⚠ {message}", file=sys.stderr, flush=True)
|
|
receipt.record_warning(message)
|