Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
139 lines
5.3 KiB
Python
139 lines
5.3 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
|
|
|
|
@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 __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}")
|
|
return PythonDeclaration(tuple(files), project, tuple(specs), manifest)
|
|
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) |