Files
hermes-agent/tests/hermes_cli/plugin_worker_support.py
ethernet 5d39ddd28b feat(pm): say when this process must restart to load the selected generation
A process that could not adopt a newly published dependency generation keeps
importing the old one. restart_needed() names that case (and stays silent for
dev venvs, Nix and anything not booted from a PM generation, so no false
restart prompts); adopt_selected() lets callers move onto the selection before
loading new code. The test helpers publish real generations and make the test
process run from one.

(cherry picked from commit 978abe8ec4b852778b8c63bcefdf141db51bc703)
(cherry picked from commit 28be27334adc531db59bf37a02a64acaaf47a2ee)
2026-09-24 23:40:17 -04:00

197 lines
9.5 KiB
Python

"""Offline plugin command fixtures; only tool acquisition is substituted.
The actual client, worker, resolver, lock, publication callbacks and receipts
run unchanged. The prepared test interpreter hosts PM in a separate process;
fresh selected interpreters never inherit its site-packages.
"""
from __future__ import annotations
import json
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
import hermes_yaml as yaml
from tests.pm._fixtures import _wheel, isolated_python as isolated_python
def worker_command(worker: Path, uv: str, python: str, *, prelude: str = "", runtime_python: str | None = None) -> list[str]:
script = (
"import runpy, sys; from pathlib import Path; "
f"sys.path.insert(0, {str(worker.parent.parent)!r}); import pm._uv; "
f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({python!r}))\n"
+ prelude + "\n"
f"runpy.run_path({str(worker)!r}, run_name='__main__')"
)
return [runtime_python or python, "-I", "-B", "-c", script]
def git(repo: Path, *args: str) -> str:
env = {k: v for k, v in os.environ.items() if k not in {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"}}
env.update(GIT_AUTHOR_NAME="fixture", GIT_AUTHOR_EMAIL="fixture@example.invalid",
GIT_COMMITTER_NAME="fixture", GIT_COMMITTER_EMAIL="fixture@example.invalid")
result = subprocess.run(["git", *args], cwd=repo, env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
def version(repo: Path, *, name="plugin-worker-proof", surface="python_dependencies", suffix="yaml", pin="1.0", capabilities=(), dependency="plugin-proof-dep") -> str:
manifest: dict = {"name": name, "version": pin}
if capabilities:
manifest["capabilities"] = list(capabilities)
specs = [f"{dependency}=={pin}"]
if surface and surface != "pyproject":
manifest[surface] = specs
(repo / f"plugin.{suffix}").write_text(yaml.safe_dump(manifest), encoding="utf-8")
(repo / "__init__.py").write_text(
f"import {dependency.replace('-', '_')} as plugin_proof_dep\nVERSION = {pin!r}\n"
"def register(ctx):\n return (VERSION, plugin_proof_dep.__version__)\n", encoding="utf-8")
if surface == "pyproject":
(repo / "pyproject.toml").write_text(
f'[project]\nname={json.dumps(name)}\nversion="{pin}"\nrequires-python=">=3.11"\n'
f'dependencies={json.dumps(specs)}\n[tool.uv]\npackage=false\n', encoding="utf-8")
git(repo, "add", ".")
git(repo, "commit", "-qm", f"fixture {pin}")
return git(repo, "rev-parse", "HEAD")
class PluginWorld:
runtime_python: str
def __init__(self, root: Path):
self.root = root
self.home = root / "home"
self.core = root / "core"
def origin(self, **kwargs) -> tuple[Path, str]:
repo = self.root / (kwargs.get("name", "plugin-worker-proof") + "-origin")
repo.mkdir()
git(repo, "init", "-qb", "main")
return repo, version(repo, **kwargs)
def command(self, action: str, **kwargs) -> None:
from hermes_cli.plugins_cmd import plugins_command
from hermes_cli.subcommands.plugins import build_plugins_parser
parser = argparse.ArgumentParser()
build_plugins_parser(parser.add_subparsers(), cmd_plugins=plugins_command)
positional = {"install": ("identifier",), "enable": ("name",), "disable": ("name",),
"pack": ("pack_action", "source")}[action]
argv = ["plugins", action, *(str(kwargs.pop(key)) for key in positional)]
for key, value in kwargs.items():
if value is True:
argv.append("--" + key.replace("_", "-"))
elif value is not None and value is not False:
argv.extend(["--" + key.replace("_", "-"), str(value)])
plugins_command(parser.parse_args(argv))
def selected(self) -> Path:
from pm.environments import selected_venv
return selected_venv(self.core)
def enabled(self) -> list[str]:
return yaml.safe_load((self.home / "config.yaml").read_text(encoding="utf-8"))["plugins"]["enabled"]
def imports(self, name="plugin-worker-proof", pin="1.0") -> None:
selected = self.selected()
python = selected / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
code = (
"import importlib.util, pathlib; import plugin_core_dep, plugin_proof_dep; "
f"assert pathlib.Path(plugin_core_dep.__file__).is_relative_to({str(selected)!r}); "
f"assert pathlib.Path(plugin_proof_dep.__file__).is_relative_to({str(selected)!r}); "
f"spec = importlib.util.spec_from_file_location('loaded_plugin', {str(self.home / 'plugins' / name / '__init__.py')!r}); "
"plugin = importlib.util.module_from_spec(spec); spec.loader.exec_module(plugin); "
f"assert plugin.register(None) == ({pin!r}, {pin!r})"
)
result = subprocess.run([str(python), "-I", "-B", "-c", code], cwd=self.root,
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
def publish_plugins(world: PluginWorld, plugins: dict[str, list[str]]) -> Path:
"""Enable exactly ``plugins`` (name → declared requirements) and publish the generation PM
builds for them. Each plugin imports its requirements, leaves an ``imported`` marker, and
``request(specs)`` asks ``install_specs`` for more on its own behalf."""
from pm import client
for name, requirements in plugins.items():
plugin = world.home / "plugins" / name
if plugin.is_dir():
continue
plugin.mkdir(parents=True)
(plugin / "plugin.yaml").write_text(yaml.safe_dump(
{"name": name, "version": "1.0", "python_dependencies": requirements}), encoding="utf-8")
modules = [spec.split("=")[0].split("<")[0].split(">")[0].replace("-", "_") for spec in requirements]
(plugin / "__init__.py").write_text(
"".join(f"import {module}\n" for module in modules)
+ "from pathlib import Path\nPath(__file__).with_name('imported').touch()\n"
"def register(ctx):\n pass\n"
"def request(specs):\n from tools.lazy_deps import install_specs\n return install_specs(specs)\n",
encoding="utf-8")
(world.home / "config.yaml").write_text(yaml.safe_dump(
{"plugins": {"enabled": sorted(plugins), "disabled": []}}), encoding="utf-8")
client.sync_venv(explicit=True)
return world.selected()
@pytest.fixture
def boot(plugin_world, monkeypatch):
"""``boot(environment)``: this test process imports from ``environment`` the way a Hermes process
booted on it does. Adoption rewrites sys.path and PATH (monkeypatch restores both); modules
imported from the world are forgotten afterwards, so a later test imports its own."""
from pm.environments import site_packages, venv_bin_dir
def run_from(environment: Path) -> None:
monkeypatch.setenv("PATH", os.pathsep.join([str(venv_bin_dir(environment)), os.defpath]))
monkeypatch.syspath_prepend(str(site_packages(environment)))
yield run_from
for name, module in list(sys.modules.items()):
if str(getattr(module, "__file__", None) or "").startswith(str(plugin_world.root)):
del sys.modules[name]
@pytest.fixture
def plugin_world(tmp_path, monkeypatch, isolated_python):
from pm import client, paths
world = PluginWorld(tmp_path)
uv = shutil.which("uv")
assert uv, "real uv is required for the plugin lifecycle"
for key in ("HERMES_MANAGED_MODE", "HERMES_INSTALL_ROOT", "VIRTUAL_ENV"):
monkeypatch.delenv(key, raising=False)
for key, value in {"HOME": tmp_path, "USERPROFILE": tmp_path, "HERMES_HOME": world.home,
"HERMES_RUNTIME_DIR": tmp_path / "store", "UV_CACHE_DIR": tmp_path / "cache",
"XDG_CONFIG_HOME": tmp_path / "config", "XDG_CONFIG_DIRS": tmp_path / "config",
"HERMES_DISABLE_LAZY_INSTALLS": "1", "UV_OFFLINE": "1"}.items():
monkeypatch.setenv(key, str(value))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(paths, "repo_root", lambda: world.core)
monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json")
world.runtime_python = str(isolated_python)
monkeypatch.setattr(client, "runtime_command", lambda worker, **kwargs:
worker_command(worker, uv, sys.executable, runtime_python=world.runtime_python))
assert not client.is_runtime(), "fixture must exercise the client/worker boundary"
wheels = tmp_path / "wheels"
wheels.mkdir()
_wheel(wheels, "plugin_core_dep")
_wheel(wheels, "plugin_proof_other")
for pin in ("1.0", "2.0"):
_wheel(wheels, "plugin_proof_dep", pin)
world.home.mkdir()
(world.home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8")
world.core.mkdir()
(world.core / "pyproject.toml").write_text(
'[project]\nname="plugin-proof-core"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=["plugin-core-dep==1.0"]\n[tool.uv]\npackage=false\nno-index=true\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8")
result = subprocess.run([uv, "lock", "--python", sys.executable], cwd=world.core,
capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr
return world