"""Tests for plugin discovery and loading.""" import json from pathlib import Path import pytest from pyra.plugins.loader import load_plugin_by_name, load_plugins def _make_plugin(plugins_dir: Path, name: str, plugin_code: str, manifest: dict | None = None) -> Path: """Helper: create a minimal plugin directory.""" plugin_dir = plugins_dir / name plugin_dir.mkdir(parents=True) if manifest is None: manifest = {"name": name, "version": "0.1.0", "description": "Test plugin"} (plugin_dir / "manifest.json").write_text(json.dumps(manifest)) (plugin_dir / "plugin.py").write_text(plugin_code) return plugin_dir _MINIMAL_PLUGIN = """\ from pyra.plugins.base import BasePlugin class _Plugin(BasePlugin): name = "test_plugin" description = "A test plugin" version = "0.1.0" def get_plugin(): return _Plugin() """ _TOOL_PLUGIN = """\ from pyra.plugins.base import BasePlugin, Tool class _Plugin(BasePlugin): name = "tool_plugin" description = "Plugin with a tool" version = "0.1.0" def tools(self): return [ Tool( name="say_hello", description="Says hello", parameters={"type": "object", "properties": {}}, handler=lambda: "hello", requires_approval=False, ) ] def get_plugin(): return _Plugin() """ def test_load_valid_plugin(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() _make_plugin(plugins_dir, "test_plugin", _MINIMAL_PLUGIN) plugin = load_plugin_by_name("test_plugin", plugins_dir) assert plugin is not None assert plugin.name == "test_plugin" def test_load_plugins_discovers_all(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() _make_plugin(plugins_dir, "plugin_a", _MINIMAL_PLUGIN.replace("test_plugin", "plugin_a")) _make_plugin(plugins_dir, "plugin_b", _MINIMAL_PLUGIN.replace("test_plugin", "plugin_b")) plugins = load_plugins(plugins_dir) names = {p.name for p in plugins} assert "plugin_a" in names assert "plugin_b" in names def test_load_plugins_empty_dir(tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() assert load_plugins(plugins_dir) == [] def test_load_plugins_missing_dir(tmp_path): assert load_plugins(tmp_path / "nonexistent") == [] def test_missing_manifest_returns_none(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() plugin_dir = plugins_dir / "bad_plugin" plugin_dir.mkdir() (plugin_dir / "plugin.py").write_text(_MINIMAL_PLUGIN) # No manifest.json result = load_plugin_by_name("bad_plugin", plugins_dir) assert result is None def test_missing_plugin_py_returns_none(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() plugin_dir = plugins_dir / "bad_plugin" plugin_dir.mkdir() (plugin_dir / "manifest.json").write_text(json.dumps({"name": "bad_plugin", "version": "1.0.0"})) # No plugin.py result = load_plugin_by_name("bad_plugin", plugins_dir) assert result is None def test_invalid_manifest_returns_none(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() plugin_dir = plugins_dir / "bad_plugin" plugin_dir.mkdir() (plugin_dir / "manifest.json").write_text('{"name": "bad_plugin"}') # missing version (plugin_dir / "plugin.py").write_text(_MINIMAL_PLUGIN) result = load_plugin_by_name("bad_plugin", plugins_dir) assert result is None def test_no_get_plugin_returns_none(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() code = "# No get_plugin function here\nclass Foo: pass" _make_plugin(plugins_dir, "no_factory", code) result = load_plugin_by_name("no_factory", plugins_dir) assert result is None def test_plugin_with_syntax_error_returns_none(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() _make_plugin(plugins_dir, "broken", "def get_plugin(: INVALID SYNTAX") result = load_plugin_by_name("broken", plugins_dir) assert result is None def test_one_bad_plugin_does_not_prevent_others(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() _make_plugin(plugins_dir, "good", _MINIMAL_PLUGIN.replace("test_plugin", "good")) _make_plugin(plugins_dir, "bad", "SYNTAX ERROR !!!") plugins = load_plugins(plugins_dir) names = [p.name for p in plugins] assert "good" in names assert len(names) == 1 def test_plugin_errors_logged(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() _make_plugin(plugins_dir, "bad", "SYNTAX ERROR") load_plugin_by_name("bad", plugins_dir) log_file = tmp_pyra_home / "logs" / "plugin_errors.log" assert log_file.exists() assert "bad" in log_file.read_text() def test_plugin_tools_accessible(tmp_pyra_home, tmp_path): plugins_dir = tmp_path / "plugins" plugins_dir.mkdir() _make_plugin(plugins_dir, "tool_plugin", _TOOL_PLUGIN) plugin = load_plugin_by_name("tool_plugin", plugins_dir) assert plugin is not None tools = plugin.tools() assert len(tools) == 1 assert tools[0].name == "say_hello"