An update resolves the enabled plugin union against the NEW core. A plugin admitted against the old core can stop fitting when core moves (managed Python 3.13 -> 3.14 vs a member's requires-python <3.14, a requires_hermes upper bound, a bumped pin), and the whole update then died after the source swap with a non-resolver InstallError whose 'retry' hint failed the same way every time. Update syncs now pass evict_incompatible_plugins=True (update completion, historical takeover, launch-time completion, venv_sync, post-update drift). PM screens statically first (requires-python vs the target interpreter, manifest/requires_hermes), then, if the rest still fails, builds core alone to prove the plugins are the cause and re-adds members in config order, disabling each one that breaks the build. Misfits land in plugins.disabled (memory.provider cleared) in every home that enables them, published through the existing journaled change hook (the journal now carries several configs), and are reported on stderr + receipt warnings. Admission and ordinary syncs still refuse; only a core that cannot build on its own fails an update.
154 lines
6.1 KiB
Python
154 lines
6.1 KiB
Python
"""Read plugin build declarations without importing CLI configuration or plugin code."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import tomllib
|
|
|
|
|
|
def native_manifest_file(plugin_dir: Path) -> Path | None:
|
|
for path in (plugin_dir / "plugin.yaml", plugin_dir / "plugin.yml"):
|
|
try:
|
|
path.stat()
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError as exc:
|
|
raise ValueError(f"Could not inspect plugin manifest {path}: {exc}") from exc
|
|
return path
|
|
return None
|
|
|
|
|
|
def read_native_manifest(path: Path) -> dict:
|
|
import hermes_yaml as yaml
|
|
|
|
try:
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8-sig"))
|
|
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
raise ValueError(f"Could not read plugin manifest {path}: {exc}") from exc
|
|
if data is None:
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"Plugin manifest must be a mapping: {path}")
|
|
return data
|
|
|
|
|
|
def manifest_version_error(manifest: dict, name: str) -> str | None:
|
|
from hermes_cli.plugins_manifest import SUPPORTED_MANIFEST_VERSION, requires_hermes_error
|
|
|
|
reason = requires_hermes_error(manifest)
|
|
if reason:
|
|
return f"Plugin '{name}' {reason}"
|
|
version = manifest.get("manifest_version")
|
|
if version is None:
|
|
return None
|
|
try:
|
|
parsed = int(version)
|
|
except (TypeError, ValueError):
|
|
return f"Plugin '{name}' has invalid manifest_version '{version}' (expected an integer)."
|
|
if parsed > SUPPORTED_MANIFEST_VERSION:
|
|
return (f"Plugin '{name}' requires manifest_version {version}, "
|
|
f"but this installer only supports up to {SUPPORTED_MANIFEST_VERSION}.")
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PythonDeclaration:
|
|
files: tuple[Path, ...]
|
|
pyproject: Path | None
|
|
requirements: tuple[str, ...]
|
|
manifest: dict
|
|
requires_python: str | None = None
|
|
|
|
@property
|
|
def external(self) -> bool:
|
|
return str(self.manifest.get("python_runtime") or "").strip().lower() == "external"
|
|
|
|
@property
|
|
def install_requirements(self) -> tuple[str, ...]:
|
|
return () if self.external else applicable_requirements(self.requirements)
|
|
|
|
@property
|
|
def is_member(self) -> bool:
|
|
return not self.external and (self.pyproject is not None or bool(self.install_requirements))
|
|
|
|
def python_error(self, version: str) -> str | None:
|
|
"""Why this member cannot join a workspace built for *version*; uv intersects every
|
|
member's requires-python, so one excluding member fails the whole lock."""
|
|
from packaging.specifiers import SpecifierSet
|
|
|
|
if not self.is_member or not self.requires_python:
|
|
return None
|
|
if SpecifierSet(self.requires_python).contains(version, prereleases=True):
|
|
return None
|
|
return f"requires Python {self.requires_python}, but Hermes runs on Python {version}"
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.external:
|
|
applicable_requirements(self.requirements)
|
|
|
|
|
|
def applicable_requirements(specs: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
"""Index requirements only; let uv evaluate markers for the target interpreter.
|
|
|
|
The application checkout supplies Hermes itself. Installing it from an index
|
|
would replace that checkout; direct URLs bypass the reviewed package source.
|
|
Markers must survive snapshots built for a different Python or platform.
|
|
"""
|
|
from packaging.requirements import Requirement
|
|
from packaging.utils import canonicalize_name
|
|
|
|
return tuple(spec for spec in specs
|
|
if not (req := Requirement(spec)).url and canonicalize_name(req.name) != "hermes-agent")
|
|
|
|
|
|
def unsupported_requirements(specs: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
from packaging.requirements import Requirement
|
|
|
|
return tuple(spec for spec in specs if Requirement(spec).url)
|
|
|
|
|
|
def read_python_declaration(plugin_dir: Path) -> PythonDeclaration:
|
|
"""One effective surface for consent, membership, builds and currency.
|
|
|
|
A real pyproject owns packaging. Legacy manifests bridge plugins without
|
|
one, including old PM-generated pyprojects. Both YAML suffixes and both
|
|
dependency aliases have identical semantics. Reading never initializes a
|
|
home, discovers code, or depends on the caller's CLI import state.
|
|
"""
|
|
native = native_manifest_file(plugin_dir)
|
|
files = [native] if native is not None else []
|
|
manifest = read_native_manifest(native) if native is not None else {}
|
|
if native is None:
|
|
portable = plugin_dir / "plugin.json"
|
|
try:
|
|
portable.lstat()
|
|
except FileNotFoundError:
|
|
pass
|
|
else:
|
|
from hermes_cli.agent_plugins import read_agent_plugin_manifest
|
|
|
|
manifest, _diagnostics = read_agent_plugin_manifest(plugin_dir)
|
|
files.append(portable)
|
|
project = plugin_dir / "pyproject.toml"
|
|
try:
|
|
text = project.read_text(encoding="utf-8-sig")
|
|
except FileNotFoundError:
|
|
text = None
|
|
if text is not None:
|
|
files.append(project)
|
|
if "GENERATED by pm" not in text:
|
|
document = tomllib.loads(text)
|
|
specs = document.get("project", {}).get("dependencies", [])
|
|
if not isinstance(specs, list) or any(not isinstance(v, str) for v in specs):
|
|
raise ValueError(f"invalid project.dependencies: {project}")
|
|
requires_python = document.get("project", {}).get("requires-python")
|
|
if requires_python is not None and not isinstance(requires_python, str):
|
|
raise ValueError(f"invalid project.requires-python: {project}")
|
|
return PythonDeclaration(tuple(files), project, tuple(specs), manifest, requires_python)
|
|
specs = []
|
|
for key in ("pip_dependencies", "python_dependencies"):
|
|
values = manifest.get(key, [])
|
|
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
|
|
raise ValueError(f"invalid {key}: {native}")
|
|
specs.extend(values)
|
|
return PythonDeclaration(tuple(files), None, tuple(dict.fromkeys(specs)), manifest) |