A plugin.yaml with no __init__.py, desktop/plugin.js or plugin.json beside it installs "successfully" and does nothing (pip-layout repos whose code sits under src/ behind an entry point). The validator and catalog CI now fail that shape and check declared Python dependencies parse and are index specs; direct-URL requirements warn. Tests: conflicting candidate refused with peers untouched; post-update re-apply drops only the culprit and keeps the memory provider. Loader wording test updated to the new hint.
86 lines
3.8 KiB
Python
86 lines
3.8 KiB
Python
"""Invariants of the plugin Python-dependency contract (hermes_cli.plugin_python_deps):
|
|
|
|
* a candidate whose declared deps conflict is REFUSED before install and nothing else changes;
|
|
* after an update, a non-memory plugin that no longer resolves is disabled loudly while memory
|
|
providers survive.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import plugin_python_deps as deps
|
|
|
|
|
|
def _plugin(root: Path, name: str, spec_lines: str, *, memory: bool = False, external: bool = False) -> Path:
|
|
d = root / "plugins" / name
|
|
d.mkdir(parents=True)
|
|
manifest = f"name: {name}\nversion: 1.0.0\n" + ("kind: exclusive\n" if memory else "")
|
|
if external:
|
|
manifest += "python_runtime: external\n"
|
|
(d / "plugin.yaml").write_text(manifest, encoding="utf-8")
|
|
(d / "__init__.py").write_text("def register(ctx):\n pass\n", encoding="utf-8")
|
|
(d / "pyproject.toml").write_text(
|
|
f'[project]\nname = "{name}"\nversion = "1.0"\ndependencies = [{spec_lines}]\n', encoding="utf-8")
|
|
return d
|
|
|
|
|
|
def _home(tmp_path: Path, enabled: list[str]) -> Path:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / "config.yaml").write_text(
|
|
"plugins:\n enabled: [" + ", ".join(enabled) + "]\n disabled: []\n", encoding="utf-8")
|
|
return home
|
|
|
|
|
|
def _fake_resolver(conflicting: set[str]):
|
|
"""Stand-in for uv: fails with a conflict whenever the spec set contains a poisoned spec."""
|
|
calls: list[list[str]] = []
|
|
|
|
def resolve(specs, constraints, *, dry_run, timeout=900):
|
|
calls.append(list(specs))
|
|
if conflicting & set(specs):
|
|
raise deps.DependencyConflict("No solution found when resolving dependencies")
|
|
|
|
return resolve, calls
|
|
|
|
|
|
def test_conflicting_candidate_is_refused_and_enabled_plugins_are_untouched(tmp_path, monkeypatch):
|
|
home = _home(tmp_path, ["good"])
|
|
_plugin(home, "good", '"tabulate>=0.9"')
|
|
candidate = _plugin(tmp_path / "staging", "bad", '"tabulate<0.9"')
|
|
resolve, calls = _fake_resolver({"tabulate<0.9"})
|
|
monkeypatch.setattr(deps, "resolve", resolve)
|
|
monkeypatch.setattr(deps, "core_constraints", lambda root: ["httpx==0.28.1"])
|
|
monkeypatch.setattr(deps, "dependency_homes", lambda: [home])
|
|
|
|
with pytest.raises(deps.DependencyConflict):
|
|
deps.check_candidate(deps.read_declaration(candidate), home=home, project_root=tmp_path)
|
|
|
|
# The dry run saw the union (candidate + enabled peer) and never ran a real install.
|
|
assert [sorted(c) for c in calls] == [["tabulate<0.9", "tabulate>=0.9"]]
|
|
assert (home / "plugins" / "good").is_dir()
|
|
assert "good" in (home / "config.yaml").read_text()
|
|
|
|
|
|
def test_reapply_disables_non_memory_plugin_loudly_and_keeps_memory_provider(tmp_path, monkeypatch):
|
|
home = _home(tmp_path, ["memory", "weather", "sidecar", "aaa-innocent"])
|
|
_plugin(home, "memory", '"mnemosyne-memory>=3"', memory=True)
|
|
_plugin(home, "weather", '"httpx<0.20"')
|
|
_plugin(home, "sidecar", '"torch==99"', external=True)
|
|
_plugin(home, "aaa-innocent", '"tabulate>=0.9"') # sorts before the culprit; must NOT be sacrificed
|
|
resolve, calls = _fake_resolver({"httpx<0.20"})
|
|
monkeypatch.setattr(deps, "resolve", resolve)
|
|
monkeypatch.setattr(deps, "core_constraints", lambda root: ["httpx==0.28.1"])
|
|
monkeypatch.setattr(deps, "dependency_homes", lambda: [home])
|
|
disabled: list[tuple[Path, str]] = []
|
|
|
|
report = deps.reapply_all(project_root=tmp_path, disable=lambda h, n: disabled.append((h, n)))
|
|
|
|
assert disabled == [(home, "weather")]
|
|
assert report.dropped and report.dropped[0][0] == "weather"
|
|
assert sorted(report.installed) == ["mnemosyne-memory>=3", "tabulate>=0.9"]
|
|
# External-runtime plugin never joined the union; memory provider was the survivor.
|
|
assert all("torch==99" not in c for c in calls)
|