Plugin dependency installs need two things the shared installer ladder did not expose: a constraints file (so a plugin can never move a core pin) and a dry run (so a conflict can be detected before anything is written). Both are threaded through _venv_pip_install; the durable-target path keeps its own core constraints and a dry run skips syspath activation and bytecode warming.
85 lines
3.6 KiB
Python
85 lines
3.6 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, "all_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 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"])
|
|
_plugin(home, "memory", '"mnemosyne-memory>=3"', memory=True)
|
|
_plugin(home, "weather", '"httpx<0.20"')
|
|
_plugin(home, "sidecar", '"torch==99"', external=True)
|
|
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, "all_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 report.installed == ["mnemosyne-memory>=3"]
|
|
# External-runtime plugin never joined the union; memory provider was the survivor.
|
|
assert all("torch==99" not in c for c in calls)
|