Run historical updater completion in a fresh interpreter so cached imports cannot revive retired dependency installers. Share Git and ZIP completion, carry receipt and recovery state, and preserve child exit status. Route plugin admission, binary acquisition, desktop launch and build paths through PM. Replace redundant helpers and tests with real worker, package, publication and launch checks. Keep the shipped compatibility surface fixed. Targeted Python and desktop checks pass. Native update journeys and fresh production image qualification remain pending. This is a checkpoint before those acceptance runs.
107 lines
4.0 KiB
Python
107 lines
4.0 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 is_member(self) -> bool:
|
|
return self.pyproject is not None or bool(self.requirements)
|
|
|
|
|
|
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) |