test_plugin_scanner_recursion.py
1 """Tests for PR1 pluggable image gen: scanner recursion, kinds, path keys. 2 3 Covers ``_scan_directory`` recursion into category namespaces 4 (``plugins/image_gen/openai/``), ``kind`` parsing, path-derived registry 5 keys, and the new gate logic (bundled backends auto-load; user backends 6 still opt-in; exclusive kind skipped; unknown kinds → standalone warning). 7 """ 8 9 from __future__ import annotations 10 11 from pathlib import Path 12 from typing import Any, Dict 13 14 import pytest 15 import yaml 16 17 from hermes_cli.plugins import PluginManager, PluginManifest 18 19 20 # ── Helpers ──────────────────────────────────────────────────────────────── 21 22 23 def _write_plugin( 24 root: Path, 25 segments: list[str], 26 *, 27 manifest_extra: Dict[str, Any] | None = None, 28 register_body: str = "pass", 29 ) -> Path: 30 """Create a plugin dir at ``root/<segments...>/`` with plugin.yaml + __init__.py. 31 32 ``segments`` lets tests build both flat (``["my-plugin"]``) and 33 category-namespaced (``["image_gen", "openai"]``) layouts. 34 """ 35 plugin_dir = root 36 for seg in segments: 37 plugin_dir = plugin_dir / seg 38 plugin_dir.mkdir(parents=True, exist_ok=True) 39 40 manifest = { 41 "name": segments[-1], 42 "version": "0.1.0", 43 "description": f"Test plugin {'/'.join(segments)}", 44 } 45 if manifest_extra: 46 manifest.update(manifest_extra) 47 (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) 48 (plugin_dir / "__init__.py").write_text( 49 f"def register(ctx):\n {register_body}\n" 50 ) 51 return plugin_dir 52 53 54 def _enable(hermes_home: Path, name: str) -> None: 55 """Append ``name`` to ``plugins.enabled`` in ``<hermes_home>/config.yaml``.""" 56 cfg_path = hermes_home / "config.yaml" 57 cfg: dict = {} 58 if cfg_path.exists(): 59 try: 60 cfg = yaml.safe_load(cfg_path.read_text()) or {} 61 except Exception: 62 cfg = {} 63 plugins_cfg = cfg.setdefault("plugins", {}) 64 enabled = plugins_cfg.setdefault("enabled", []) 65 if isinstance(enabled, list) and name not in enabled: 66 enabled.append(name) 67 cfg_path.write_text(yaml.safe_dump(cfg)) 68 69 70 # ── Scanner recursion ────────────────────────────────────────────────────── 71 72 73 class TestCategoryNamespaceRecursion: 74 def test_category_namespace_discovered(self, tmp_path, monkeypatch): 75 """``<root>/image_gen/openai/plugin.yaml`` is discovered with key 76 ``image_gen/openai`` when the ``image_gen`` parent has no manifest.""" 77 import os 78 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 79 user_plugins = hermes_home / "plugins" 80 81 _write_plugin(user_plugins, ["image_gen", "openai"]) 82 _enable(hermes_home, "image_gen/openai") 83 84 mgr = PluginManager() 85 mgr.discover_and_load() 86 87 assert "image_gen/openai" in mgr._plugins 88 loaded = mgr._plugins["image_gen/openai"] 89 assert loaded.manifest.key == "image_gen/openai" 90 assert loaded.manifest.name == "openai" 91 assert loaded.enabled is True 92 93 def test_flat_plugin_key_matches_name(self, tmp_path, monkeypatch): 94 """Flat plugins keep their bare name as the key (back-compat).""" 95 import os 96 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 97 user_plugins = hermes_home / "plugins" 98 99 _write_plugin(user_plugins, ["my-plugin"]) 100 _enable(hermes_home, "my-plugin") 101 102 mgr = PluginManager() 103 mgr.discover_and_load() 104 105 assert "my-plugin" in mgr._plugins 106 assert mgr._plugins["my-plugin"].manifest.key == "my-plugin" 107 108 def test_depth_cap_two(self, tmp_path, monkeypatch): 109 """Plugins nested three levels deep are not discovered. 110 111 ``<root>/a/b/c/plugin.yaml`` should NOT be picked up — cap is 112 two segments. 113 """ 114 import os 115 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 116 user_plugins = hermes_home / "plugins" 117 118 _write_plugin(user_plugins, ["a", "b", "c"]) 119 120 mgr = PluginManager() 121 mgr.discover_and_load() 122 123 non_bundled = [ 124 k for k, p in mgr._plugins.items() 125 if p.manifest.source != "bundled" 126 ] 127 assert non_bundled == [] 128 129 def test_category_dir_with_manifest_is_leaf(self, tmp_path, monkeypatch): 130 """If ``image_gen/plugin.yaml`` exists, ``image_gen`` itself IS the 131 plugin and its children are ignored.""" 132 import os 133 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 134 user_plugins = hermes_home / "plugins" 135 136 # parent has a manifest → stop recursing 137 _write_plugin(user_plugins, ["image_gen"]) 138 # child also has a manifest — should NOT be found because we stop 139 # at the parent. 140 _write_plugin(user_plugins, ["image_gen", "openai"]) 141 _enable(hermes_home, "image_gen") 142 _enable(hermes_home, "image_gen/openai") 143 144 mgr = PluginManager() 145 mgr.discover_and_load() 146 147 # The bundled plugins/image_gen/openai/ exists in the repo — filter 148 # it out so we're only asserting on the user-dir layout. 149 user_plugins_in_registry = { 150 k for k, p in mgr._plugins.items() if p.manifest.source != "bundled" 151 } 152 assert "image_gen" in user_plugins_in_registry 153 assert "image_gen/openai" not in user_plugins_in_registry 154 155 156 # ── Kind parsing ─────────────────────────────────────────────────────────── 157 158 159 class TestKindField: 160 def test_default_kind_is_standalone(self, tmp_path, monkeypatch): 161 import os 162 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 163 _write_plugin(hermes_home / "plugins", ["p1"]) 164 _enable(hermes_home, "p1") 165 166 mgr = PluginManager() 167 mgr.discover_and_load() 168 169 assert mgr._plugins["p1"].manifest.kind == "standalone" 170 171 @pytest.mark.parametrize("kind", ["backend", "exclusive", "standalone"]) 172 def test_valid_kinds_parsed(self, kind, tmp_path, monkeypatch): 173 import os 174 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 175 _write_plugin( 176 hermes_home / "plugins", 177 ["p1"], 178 manifest_extra={"kind": kind}, 179 ) 180 # Not all kinds auto-load, but manifest should parse. 181 _enable(hermes_home, "p1") 182 183 mgr = PluginManager() 184 mgr.discover_and_load() 185 186 assert "p1" in mgr._plugins 187 assert mgr._plugins["p1"].manifest.kind == kind 188 189 def test_unknown_kind_falls_back_to_standalone(self, tmp_path, monkeypatch, caplog): 190 import os 191 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 192 _write_plugin( 193 hermes_home / "plugins", 194 ["p1"], 195 manifest_extra={"kind": "bogus"}, 196 ) 197 _enable(hermes_home, "p1") 198 199 with caplog.at_level("WARNING"): 200 mgr = PluginManager() 201 mgr.discover_and_load() 202 203 assert mgr._plugins["p1"].manifest.kind == "standalone" 204 assert any( 205 "unknown kind" in rec.getMessage() for rec in caplog.records 206 ) 207 208 209 # ── Gate logic ───────────────────────────────────────────────────────────── 210 211 212 class TestBackendGate: 213 def test_user_backend_still_gated_by_enabled(self, tmp_path, monkeypatch): 214 """User-installed ``kind: backend`` plugins still require opt-in — 215 they're not trusted by default.""" 216 import os 217 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 218 user_plugins = hermes_home / "plugins" 219 220 _write_plugin( 221 user_plugins, 222 ["image_gen", "fancy"], 223 manifest_extra={"kind": "backend"}, 224 ) 225 # Do NOT opt in. 226 227 mgr = PluginManager() 228 mgr.discover_and_load() 229 230 loaded = mgr._plugins["image_gen/fancy"] 231 assert loaded.enabled is False 232 assert "not enabled" in (loaded.error or "") 233 234 def test_user_backend_loads_when_enabled(self, tmp_path, monkeypatch): 235 import os 236 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 237 user_plugins = hermes_home / "plugins" 238 239 _write_plugin( 240 user_plugins, 241 ["image_gen", "fancy"], 242 manifest_extra={"kind": "backend"}, 243 ) 244 _enable(hermes_home, "image_gen/fancy") 245 246 mgr = PluginManager() 247 mgr.discover_and_load() 248 249 assert mgr._plugins["image_gen/fancy"].enabled is True 250 251 def test_exclusive_kind_skipped(self, tmp_path, monkeypatch): 252 """``kind: exclusive`` plugins are recorded but not loaded — the 253 category's own discovery system handles them (memory today).""" 254 import os 255 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 256 _write_plugin( 257 hermes_home / "plugins", 258 ["some-backend"], 259 manifest_extra={"kind": "exclusive"}, 260 ) 261 _enable(hermes_home, "some-backend") 262 263 mgr = PluginManager() 264 mgr.discover_and_load() 265 266 loaded = mgr._plugins["some-backend"] 267 assert loaded.enabled is False 268 assert "exclusive" in (loaded.error or "") 269 270 271 # ── Bundled backend auto-load (integration with real bundled plugin) ──────── 272 273 274 class TestBundledBackendAutoLoad: 275 def test_bundled_image_gen_openai_autoloads(self, tmp_path, monkeypatch): 276 """The bundled ``plugins/image_gen/openai/`` plugin loads without 277 any opt-in — it's ``kind: backend`` and shipped in-repo.""" 278 import os 279 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 280 281 mgr = PluginManager() 282 mgr.discover_and_load() 283 284 assert "image_gen/openai" in mgr._plugins 285 loaded = mgr._plugins["image_gen/openai"] 286 assert loaded.manifest.source == "bundled" 287 assert loaded.manifest.kind == "backend" 288 assert loaded.enabled is True, f"error: {loaded.error}" 289 290 291 # ── PluginContext.register_image_gen_provider ─────────────────────────────── 292 293 294 class TestRegisterImageGenProvider: 295 def test_accepts_valid_provider(self, tmp_path, monkeypatch): 296 from agent import image_gen_registry 297 from agent.image_gen_provider import ImageGenProvider 298 299 image_gen_registry._reset_for_tests() 300 301 class FakeProvider(ImageGenProvider): 302 @property 303 def name(self) -> str: 304 return "fake-test" 305 306 def generate(self, prompt, aspect_ratio="landscape", **kw): 307 return {"success": True, "image": "test://fake"} 308 309 import os 310 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 311 plugin_dir = _write_plugin( 312 hermes_home / "plugins", 313 ["my-img-plugin"], 314 register_body=( 315 "from agent.image_gen_provider import ImageGenProvider\n" 316 " class P(ImageGenProvider):\n" 317 " @property\n" 318 " def name(self): return 'fake-ctx'\n" 319 " def generate(self, prompt, aspect_ratio='landscape', **kw):\n" 320 " return {'success': True, 'image': 'x://y'}\n" 321 " ctx.register_image_gen_provider(P())" 322 ), 323 ) 324 _enable(hermes_home, "my-img-plugin") 325 326 mgr = PluginManager() 327 mgr.discover_and_load() 328 329 assert mgr._plugins["my-img-plugin"].enabled is True 330 assert image_gen_registry.get_provider("fake-ctx") is not None 331 332 image_gen_registry._reset_for_tests() 333 334 def test_rejects_non_provider(self, tmp_path, monkeypatch, caplog): 335 from agent import image_gen_registry 336 337 image_gen_registry._reset_for_tests() 338 339 import os 340 hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture 341 _write_plugin( 342 hermes_home / "plugins", 343 ["bad-img-plugin"], 344 register_body="ctx.register_image_gen_provider('not a provider')", 345 ) 346 _enable(hermes_home, "bad-img-plugin") 347 348 with caplog.at_level("WARNING"): 349 mgr = PluginManager() 350 mgr.discover_and_load() 351 352 # Plugin loaded (register returned normally) but nothing was 353 # registered in the provider registry. 354 assert mgr._plugins["bad-img-plugin"].enabled is True 355 assert image_gen_registry.get_provider("not a provider") is None 356 357 image_gen_registry._reset_for_tests()