fix(plugins): --install-deps probe imports from the synced environment
hermes plugins validate --install-deps synced the plugin's declared dependencies into a newly committed environment, then ran the capability probe with sys.executable, the pre-sync interpreter. Plugins importing a non-core package at load time (evalroute: pyyaml, gone from core since the ruamel move) failed the catalog gate with a masked ImportError. Run the probe with the committed environment's interpreter and activation env. A developer venv without a committed environment keeps the old path.
This commit is contained in:
@@ -324,20 +324,25 @@ def _probe_options(manifest: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _run_capability_probe(plugin_dir: Path, manifest: dict) -> Tuple[Optional[dict], str]:
|
||||
def _run_capability_probe(
|
||||
plugin_dir: Path, manifest: dict, probe: Optional[Tuple[Path, Dict[str, str]]] = None,
|
||||
) -> Tuple[Optional[dict], str]:
|
||||
"""Run the recording probe in a scratch subprocess.
|
||||
|
||||
*probe* is ``(interpreter, env)`` of the dependency environment to import the plugin from;
|
||||
None probes this interpreter.
|
||||
|
||||
Returns ``(recorded, error)`` — exactly one is meaningful: *recorded*
|
||||
is the ``{tools, hooks, middleware, commands, providers}`` dict on
|
||||
success, and *error* is a human-readable failure description otherwise.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-validate-") as scratch:
|
||||
env = dict(os.environ)
|
||||
env = dict(probe[1] if probe else os.environ)
|
||||
env["HERMES_HOME"] = scratch
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(probe[0]) if probe else sys.executable,
|
||||
"-c",
|
||||
_PROBE_SCRIPT,
|
||||
str(plugin_dir),
|
||||
@@ -379,7 +384,8 @@ def _declared_list(manifest: dict, key: str) -> List[str]:
|
||||
|
||||
|
||||
def _check_capabilities(
|
||||
report: ValidationReport, manifest: dict, plugin_dir: Path
|
||||
report: ValidationReport, manifest: dict, plugin_dir: Path,
|
||||
probe: Optional[Tuple[Path, Dict[str, str]]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Probe actual registrations and diff against declared capabilities.
|
||||
|
||||
@@ -393,7 +399,7 @@ def _check_capabilities(
|
||||
report.add("capability probe", True, "skipped (no __init__.py)")
|
||||
return None
|
||||
|
||||
recorded, error = _run_capability_probe(plugin_dir, manifest)
|
||||
recorded, error = _run_capability_probe(plugin_dir, manifest, probe)
|
||||
if recorded is None:
|
||||
report.add("capability probe", False, error)
|
||||
return None
|
||||
@@ -472,8 +478,11 @@ def _check_builtin_collisions(
|
||||
# ─── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_plugin_dir(plugin_dir: Path) -> ValidationReport:
|
||||
"""Run every admission check against *plugin_dir* and return the report."""
|
||||
def validate_plugin_dir(
|
||||
plugin_dir: Path, probe: Optional[Tuple[Path, Dict[str, str]]] = None,
|
||||
) -> ValidationReport:
|
||||
"""Run every admission check against *plugin_dir* and return the report. *probe* is
|
||||
``(interpreter, env)`` for the capability probe (see ``_run_capability_probe``)."""
|
||||
report = ValidationReport()
|
||||
plugin_dir = Path(plugin_dir)
|
||||
|
||||
@@ -520,7 +529,7 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport:
|
||||
_check_requires_env(report, manifest)
|
||||
_check_loadable(report, plugin_dir, manifest)
|
||||
_check_python_dependencies(report, plugin_dir)
|
||||
recorded = _check_capabilities(report, manifest, plugin_dir)
|
||||
recorded = _check_capabilities(report, manifest, plugin_dir, probe)
|
||||
_check_builtin_collisions(report, manifest, recorded)
|
||||
_check_trusted_inbound(report, recorded)
|
||||
_check_security_scan(report, plugin_dir)
|
||||
|
||||
@@ -770,14 +770,22 @@ def cmd_validate(path: str, as_json: bool = False, install_deps: bool = False) -
|
||||
installs the declared Python deps first so the capability probe imports what an install would."""
|
||||
from hermes_cli.plugin_validate import validate_plugin_dir
|
||||
from hermes_cli.plugins_cmd import _console
|
||||
probe = None
|
||||
if install_deps:
|
||||
import pm
|
||||
from pm import paths
|
||||
from pm.environments import activation_environment, project_python
|
||||
from pm.plugin_inputs import Candidates
|
||||
try:
|
||||
pm.sync_venv(plugins=Candidates([Path(path)]))
|
||||
# The sync commits a NEW environment that this running process never switches to,
|
||||
# so the probe must import the plugin from that environment's interpreter.
|
||||
root = paths.repo_root()
|
||||
if project_python(root).is_file(): # a developer venv has no committed environment
|
||||
probe = (project_python(root), activation_environment(root))
|
||||
except Exception as exc: # validation still runs; the probe reports what is missing
|
||||
print(f"dependency preparation failed: {exc}", file=sys.stderr)
|
||||
report = validate_plugin_dir(Path(path))
|
||||
report = validate_plugin_dir(Path(path), probe)
|
||||
if as_json:
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
sys.exit(report.exit_code)
|
||||
|
||||
@@ -450,3 +450,31 @@ def test_core_override_through_a_method_patch_helper(tmp_path):
|
||||
from hermes_cli.plugin_validate_core_override import core_override_findings
|
||||
|
||||
assert core_override_findings(bad) == ["bind(auxiliary_client, ...) (__init__.py:6)"]
|
||||
|
||||
|
||||
def test_install_deps_probe_imports_from_the_synced_environment(tmp_path: Path, monkeypatch, capsys) -> None:
|
||||
"""`--install-deps` commits a new dependency environment this process never switches to;
|
||||
the probe must import the plugin from that environment, not the validator's own."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pm
|
||||
import pm.environments as environments
|
||||
from hermes_cli.plugins_cmd_catalog import cmd_validate
|
||||
|
||||
deps = tmp_path / "synced-site-packages"
|
||||
deps.mkdir()
|
||||
(deps / "probe_only_dep.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||
plugin = _make_plugin(tmp_path, manifest={"name": "needs-dep", "version": "1.0.0", "description": "d"},
|
||||
init_py="import probe_only_dep\n\ndef register(ctx):\n pass\n")
|
||||
synced_env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(Path(__file__).resolve().parents[2]), str(deps)])}
|
||||
monkeypatch.setattr(pm, "sync_venv", lambda **_kw: None)
|
||||
monkeypatch.setattr(environments, "project_python", lambda _root: Path(sys.executable))
|
||||
monkeypatch.setattr(environments, "activation_environment", lambda _root: synced_env)
|
||||
|
||||
try:
|
||||
cmd_validate(str(plugin), as_json=True, install_deps=True)
|
||||
except SystemExit:
|
||||
pass
|
||||
checks = {c["name"]: c for c in json.loads(capsys.readouterr().out)["checks"]}
|
||||
assert checks["capability probe"]["ok"], checks["capability probe"]["detail"]
|
||||
|
||||
Reference in New Issue
Block a user