Files
hermes-agent/tests/pm/test_plugin_declarations.py
ethernet b4a294fff9 Merge origin/main; keep PM as plugin dependency owner
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.
2026-09-17 13:52:05 -04:00

101 lines
4.5 KiB
Python

"""Plugin declaration safeguards from #113851, through PM's real resolver."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tomllib
import pytest
from pm.environment import PythonEnvironment
from pm.plugin_declarations import read_python_declaration, unsupported_requirements
from pm.workspace import enabled_member_dirs, lock_and_sync
from tests.pm import _fixtures
@pytest.mark.parametrize("modern", [False, True])
def test_declaration_policy_survives_cross_profile_resolution(tmp_path, monkeypatch, modern):
home = tmp_path / "custom-home"
sibling = home / "profiles" / "work"
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(sibling))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "runtime"))
core = tmp_path / "core"
core.mkdir()
wheels = tmp_path / "wheels"
wheels.mkdir()
_fixtures._wheel(wheels, "fixturedep", "1.0")
(core / "pyproject.toml").write_text(
'[project]\nname="core"\nversion="1"\nrequires-python=">=3.11"\n'
'[tool.uv]\npackage=false\nno-index=true\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
)
plugin = home / "plugins" / "member"
external = sibling / "plugins" / "sidecar"
plugin.mkdir(parents=True)
external.mkdir(parents=True)
(home / "config.yaml").write_text("plugins:\n enabled: [member]\n", encoding="utf-8")
(sibling / "config.yaml").write_text("memory:\n provider: sidecar\n", encoding="utf-8")
(external / "plugin.yaml").write_text(
'name: sidecar\npython_runtime: external\npython_dependencies: ["impossible==99"]\n',
encoding="utf-8",
)
(external / "pyproject.toml").write_text(
'[project]\nname="sidecar"\nversion="1"\ndependencies=["impossible==99"]\n',
encoding="utf-8",
)
specs = ['fixturedep>=1,<2', 'hermes-agent>=0.1,<1',
'remote @ https://example.invalid/unreviewed.whl',
'missing-other-python; python_version < "3.0"']
(plugin / "plugin.yaml").write_text(
'name: member\npython_dependencies: ' + json.dumps(["ignored==99"] if modern else specs) + '\n',
encoding="utf-8",
)
if modern:
(plugin / "pyproject.toml").write_text(
'[project]\nname="member"\nversion="1"\nrequires-python=">=3.11"\n'
f'dependencies={json.dumps(specs)}\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
before = {p: p.read_bytes() for directory in (plugin, external) for p in directory.iterdir()}
assert enabled_member_dirs() == [plugin]
assert read_python_declaration(external).external
declaration = read_python_declaration(plugin)
assert declaration.requirements == tuple(specs)
assert unsupported_requirements(declaration.requirements) == (specs[2],)
uv = shutil.which("uv")
assert uv is not None, "real resolver is required"
environment = PythonEnvironment(
uv=Path(uv), python=Path(sys.executable), destination=tmp_path / "env",
cache=tmp_path / "cache", env=dict(os.environ), offline=True,
)
root = tmp_path / "snapshot"
lock_and_sync(enabled_member_dirs(), [], root=root, source=core, seed_lock=None,
environment=environment)
probe = subprocess.run(
[str(environment.executable), "-I", "-c", "import fixturedep; print(fixturedep.__version__)"],
check=True, capture_output=True, text=True, timeout=30,
)
assert probe.stdout.strip() == "1.0"
[relative] = tomllib.loads((root / "pyproject.toml").read_text())["tool"]["uv"]["workspace"]["members"]
generated = tomllib.loads((root / relative / "pyproject.toml").read_text())["project"]["dependencies"]
assert generated == [specs[0], specs[3]], "target markers must reach uv, unsafe/self requirements must not"
assert all(p.read_bytes() == body for p, body in before.items())
@pytest.mark.parametrize("modern", [False, True])
def test_invalid_requirement_refused_at_declaration_boundary(tmp_path, modern):
if modern:
path = tmp_path / "pyproject.toml"
path.write_text('[project]\ndependencies=["not a requirement >="]\n', encoding="utf-8")
else:
path = tmp_path / "plugin.yaml"
path.write_text('python_dependencies: ["not a requirement >="]\n', encoding="utf-8")
before = path.read_bytes()
with pytest.raises(ValueError):
read_python_declaration(tmp_path)
assert path.read_bytes() == before