Files
hermes-agent/pm/plugin_declarations.py
T
Teknium 6661080238 fix(pm): a plugin whose pyproject installs nothing no longer rebuilds the dependency environment
Enabling or updating a plugin whose pyproject.toml declares `dependencies = []` (the official
agent-importers plugin) ran "Resolving / Installing Python dependencies" and built a new
environment generation although nothing gets installed, and every update of the plugin did it
again.

Workspace membership keyed on "has a pyproject", so such a plugin became a uv workspace member,
and members_stamp hashes a member's whole tree into the venv stamp: enabling it, or any change to
its files, made the recorded environment stale. A plugin is now a member only when it installs
packages (`PythonDeclaration.installs_packages`, the predicate dependency consent already uses
since the sibling fix for the empty consent step), so membership, requires-python checks and the
stamp all follow what the plugin actually puts in the environment.

Test fixtures that used a dep-less pyproject as a cheap stand-in for a member now declare a real
dependency (a local wheel through tool.uv.sources, resolvable offline, or a requirement where no
resolution runs); their assertions are unchanged. New: enabling a `dependencies = []` plugin
through the real admission path keeps the selected generation and recorded stamp, a later change
to its files keeps the venv current, and a plugin with a real dependency still joins.
2026-10-22 11:12:32 -07:00

166 lines
7.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
# The pyproject itself installs something: the plugin as a package, or extras/dependency groups
# (``uv sync --all-packages`` installs a member's default groups as well as its dependencies).
pyproject_installs: bool = False
@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 installs_packages(self) -> bool:
"""Admission would put packages into the shared environment: what dependency consent asks
about and what makes the plugin a workspace member. ``dependencies = []`` installs nothing,
so like no pyproject it asks no consent and stays out of the environment stamp, which hashes
a member's whole tree (each enable or update would build a generation for nothing)."""
return not self.external and (bool(self.install_requirements) or self.pyproject_installs)
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.installs_packages 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}")
uv = document.get("tool", {}).get("uv", {})
installs = ("build-system" in document or uv.get("package") is True
or any(document.get("project", {}).get("optional-dependencies", {}).values())
or any(document.get("dependency-groups", {}).values())
or bool(uv.get("dev-dependencies")))
return PythonDeclaration(tuple(files), project, tuple(specs), manifest, requires_python, installs)
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)