diff --git a/shepherd/agent/plugin.py b/shepherd/agent/plugin.py index e8fab61..db58fe7 100644 --- a/shepherd/agent/plugin.py +++ b/shepherd/agent/plugin.py @@ -4,6 +4,8 @@ import inspect import logging import sys import pkgutil +from collections import namedtuple +from types import MappingProxyType import pkg_resources from configspec import ConfigSpecification from .. import base_plugins @@ -11,6 +13,12 @@ from .. import base_plugins log = logging.getLogger(__name__) +_loaded_plugins = {} + +plugin_interfaces = {} + +plugin_functions = {} + class PluginLoadError(Exception): pass @@ -36,6 +44,16 @@ def plugin_attachment(hookname): return wrapped +class InterfaceFunction(): + def __init__(self, func): + if not callable(func): + raise TypeError("Argument to InterfaceFunction must be callable") + self.func = func + + def __call__(self, *args, **kwargs): + return self.func(*args, **kwargs) + + class PluginInterface(): @staticmethod def _load_interface(module, plugin_name) -> "PluginInterface": @@ -58,6 +76,14 @@ class PluginInterface(): interface._plugin_name = plugin_name return interface + def __init__(self): + self._confspec = None + self._loaded = False + self._functions = {} + self.config = None + self.plugins = None + self._plugin_name = "" + def _load_confspec(self, module): """ If not already registered, looks for a ConfigSpecification instance in a plugin, @@ -82,11 +108,6 @@ class PluginInterface(): def _load_pluginclass(self, module): pass - def __init__(self): - self._confspec = None - self._loaded = False - self._plugin_name = "" - def _load_guard(self): if self._loaded: raise PluginLoadError("Cannot call interface register functions once" @@ -98,6 +119,17 @@ class PluginInterface(): raise PluginLoadError("confspec must be an instance of ConfigSpecification") self._confspec = confspec + def register_function(self, func, name=None): + self._load_guard() + + if not name: + name = func.__name__ + + if name in self._functions: + raise PluginLoadError(F"Interface function with name '{name}' already exists") + + self._functions[name] = InterfaceFunction(func) + @property def confspec(self): return self._confspec @@ -132,9 +164,6 @@ def discover_installed_plugins(): return [entrypoint.name for entrypoint in pkg_resources.iter_entry_points('shepherd.plugins')] -loaded_plugins = {} - - def load_plugin(plugin_name, plugin_dir=None): """ Finds a Shepherd plugin, loads it, and returns the resulting PluginInterface object. @@ -153,8 +182,8 @@ def load_plugin(plugin_name, plugin_dir=None): Returns: The PluginInterface for the loaded plugin """ - if plugin_name in loaded_plugins: - return loaded_plugins[plugin_name] + if plugin_name in _loaded_plugins: + return _loaded_plugins[plugin_name] # Each of the 3 plugin sources have different import mechanisms. Discovery is broken out to # allow them to be listed. Using a try/except block wouldn't be able to tell the difference @@ -185,10 +214,29 @@ def load_plugin(plugin_name, plugin_dir=None): interface._load_confspec(mod) # TODO Populate plugin interface + interface._loaded = True - loaded_plugins[plugin_name] = interface + _loaded_plugins[plugin_name] = interface return interface def init_plugins(plugin_configs, core_config): - pass + """ + Initialise plugins named as keys in plugin_configs. Plugins must already be loaded. + """ + # Run plugin init and init hooks + plugin_names = plugin_configs.keys() + + for plugin_name in plugin_names: + plugin_interfaces[plugin_name] = _loaded_plugins[plugin_name] + plugin_functions[plugin_name] = _loaded_plugins[plugin_name]._functions + + plugin_functions_tuples = {} + for name, functions in plugin_functions.items(): + plugin_functions_tuples[name] = namedtuple( + F'{name}_interface_functions', functions.keys())(**functions) + + plugin_functions_view = MappingProxyType(plugin_functions_tuples) + + for name, plugin_interface in plugin_interfaces.items(): + plugin_interface.plugins = plugin_functions_view diff --git a/tests/dirtytestplugin.py b/tests/dirtytestplugin.py new file mode 100644 index 0000000..ae4c9cf --- /dev/null +++ b/tests/dirtytestplugin.py @@ -0,0 +1,12 @@ +from configspec import * +from shepherd import PluginInterface + +interface = PluginInterface() + +confspec = ConfigSpecification() +confspec.add_spec("spec1", StringSpec()) + +confspec2 = ConfigSpecification() +confspec2.add_spec("spec2", StringSpec()) + +interface.register_confspec(confspec2) diff --git a/tests/simpletestplugin.py b/tests/simpletestplugin.py index ae4c9cf..691e4c7 100644 --- a/tests/simpletestplugin.py +++ b/tests/simpletestplugin.py @@ -6,7 +6,11 @@ interface = PluginInterface() confspec = ConfigSpecification() confspec.add_spec("spec1", StringSpec()) -confspec2 = ConfigSpecification() -confspec2.add_spec("spec2", StringSpec()) +interface.register_confspec(confspec) -interface.register_confspec(confspec2) + +def my_interface_function(): + return 42 + + +interface.register_function(my_interface_function) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 998e0e8..cc658c4 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,11 +1,51 @@ +# pylint: disable=redefined-outer-name import pytest from shepherd.agent import plugin -def test_simple_plugin_load(request): +@pytest.fixture +def simple_plugin(request): + interface = plugin.load_plugin("simpletestplugin", request.fspath.dirname) + return interface + + +def test_simple_plugin_load(simple_plugin: plugin.PluginInterface): # If successful, will load as if it's a custom plugin + assert simple_plugin._plugin_name == "simpletestplugin" + + +def test_simple_interface_function_load(simple_plugin: plugin.PluginInterface): + + # Check register_function() + assert "my_interface_function" in simple_plugin._functions + + +@pytest.fixture +def simple_running_plugin(request): interface = plugin.load_plugin("simpletestplugin", request.fspath.dirname) + template_config = interface.confspec.get_template() + plugin.init_plugins({"simpletestplugin": template_config}, {"core_conf": "core_conf_vals"}) + return interface + + +def test_simple_plugin_init(simple_running_plugin): + assert "simpletestplugin" in plugin.plugin_interfaces + + +def test_simple_interface_functions(simple_running_plugin): + + # Check module level function dict + assert plugin.plugin_functions["simpletestplugin"]["my_interface_function"]() == 42 + + # Check functions handed back to plugin + assert simple_running_plugin.plugins["simpletestplugin"].my_interface_function() == 42 + + +def test_dirty_plugin_load(request): + """ + Corner cases in plugin load + """ + interface = plugin.load_plugin("dirtytestplugin", request.fspath.dirname) - # Should prefer the confspec actually registered + # Should prefer the confspec actually registered, even if declared after assert "spec2" in interface.confspec.spec_dict - print(interface)