Sitting a plugin out after a fetch failure left the recorded stamp stale on purpose, so every launch resynced, and the spawned-process guard in prepare_launch raised "dependency sync left this install out of date". One retry covers a blip; after that the plugin is disabled with the reason, and hermes plugins enable restores it. Only requires_hermes still sits out, which boot skips the same way.
208 lines
10 KiB
Python
208 lines
10 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. A fetch or
|
|
tooling failure could be the moment, so the plugin gets one retry before it is disabled.
|
|
requires_hermes is judged against a version identity that can lag (a checkout without its
|
|
release tags), so a misfit there only sits out: config is untouched, boot skips it the same
|
|
way, and it rejoins when the verdict flips. A secondary profile whose config cannot be read
|
|
sits out 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, 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 _trial(package, enabled, explicit: bool, plugin_dirs: list[Path]) -> str | None:
|
|
"""Why the last of *plugin_dirs* cannot join the build, or None when it builds."""
|
|
cause = ""
|
|
# A fetch or tooling failure can be the moment rather than the plugin: one more try.
|
|
for _attempt in range(2):
|
|
try:
|
|
package.apply(enabled, explicit=explicit, plugin_dirs=plugin_dirs, skip_invalid_secondary=True)
|
|
return None
|
|
except (ResolutionConflict, BuildFailure) as exc:
|
|
return f"the dependency environment no longer builds with it: {exc.cause[-400:]}"
|
|
except InstallError as exc:
|
|
cause = exc.cause
|
|
return f"its dependencies could not be prepared, twice: {cause[-400:]}"
|
|
|
|
|
|
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:
|
|
reason = _trial(package, enabled, explicit, [*fitting, member])
|
|
if reason:
|
|
reasons[member.resolve()] = reason
|
|
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 Hermes reports a version it accepts")
|
|
for message in notices:
|
|
print(f"⚠ {message}", file=sys.stderr, flush=True)
|
|
receipt.record_warning(message)
|