You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
shepherd-agent/shepherd/agent/plugin.py

283 lines
9.4 KiB

#!/usr/bin/env python3
import inspect
from abc import ABC, abstractmethod
import importlib
import logging
from types import SimpleNamespace
from collections import namedtuple
import sys
import os
import shepherd.scheduler
log = logging.getLogger(__name__)
class PluginLoadError(Exception):
pass
class Hook():
def __init__(self):
self.attached_functions = []
def attach(self, new_func):
if not callable(new_func):
raise TypeError("Argument to Hook.attach must be callable")
self.attached_functions.append(new_func)
def __call__(self, *args, **kwargs):
for func in self.attached_functions:
func(*args, **kwargs)
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)
# TODO: Create tests to try and make a plugin subclass, and check exceptions to see if they
# suggest the correct abstractmethods
class Plugin(ABC):
@staticmethod
@abstractmethod
def specify_config(confspec):
pass
@abstractmethod
def __init__(self, plugininterface, config):
pass
def run(self, hooks, plugins):
pass
class SimplePlugin(Plugin):
@staticmethod
def specify_config(confspec):
pass
def __init__(self, plugininterface, config):
super().__init__(plugininterface, config)
self.config = config
self.interface = plugininterface
self.plugins = plugininterface.other_plugins
self.hooks = plugininterface.hooks
plugin_interfaces = {} # dict of plugin interfaces
# convenience dicts bundling together lists from interfaces
plugin_functions = {} # dict of plugins containing callable interface functions
plugin_hooks = {} # dict of plugins containing hook namespaces
_defer = True
_deferred_attachments = []
_deferred_jobs = []
def init_plugins(plugin_classes, plugin_configs, core_config):
# Startup pluginmanagers
global plugin_interfaces
global plugin_functions
global plugin_hooks
global _defer
global _deferred_attachments
global _deferred_jobs
for name, plugin_class in plugin_classes.items():
# Instanciate the plugin interface - this also instanciates
# the plugin
plugin_interfaces[name] = PluginInterface(
name, plugin_class, plugin_configs[name], core_config)
plugin_functions[name] = plugin_interfaces[name].functions
plugin_hooks[name] = plugin_interfaces[name].hooks
# interfaces and hooks should now be populated, attach hooks, schedule jobs
_defer = False
for attachment in _deferred_attachments:
_attach_hook(attachment)
for job_desc in _deferred_jobs:
_add_job(job_desc)
# Hand shared interface callables back out
for plugininterface in plugin_interfaces.values():
plugininterface.functions = plugin_functions
def _add_job(job_desc):
global _deferred_jobs
global _defer
if not _defer:
shepherd.scheduler.schedule_job(job_desc)
else:
_deferred_jobs.append(job_desc)
def _attach_hook(attachment):
global plugin_hooks
global _deferred_attachments
global _defer
if not _defer:
targetplugin_hooks = plugin_hooks.get(attachment.pluginname)
if targetplugin_hooks is not None:
targethook = getattr(targetplugin_hooks, attachment.hookname)
if targethook is not None:
targethook.attach(attachment.func)
else:
raise Exception("Could not find hook '" +
attachment.hookname+"' in module '"+attachment.pluginname+"'")
else:
raise Exception(
"Cannot attach hook to non-existing module '"+attachment.pluginname+"'")
else:
_deferred_attachments.append(attachment)
# Eventually, would like to be able to have client plugin simply:
# self.shepherd.add_job(trigger, self.interface.myfunc)
# self.shepherd.attach_hook(pluginnanme,hookname, callable)
# self.shepherd.addinterface
# self.shepherd.hooks.myhook()
# self.shepherd.plugins.otherplugin.otherinterface()
# self.shepherd.add_job()
# Would be good to be able to use abstract methods to enable simpler plugin config
# defs. A way to avoid instantiating the class would be to run it all as class methods,
# enabling
HookAttachment = namedtuple(
'HookAttachment', ['pluginname', 'hookname', 'func'])
class PluginInterface():
'''
Class to handle the management of a single plugin.
All interaction to or from the plugin to other Shepherd components or
plugins should go through here.
'''
def __init__(self, pluginname, pluginclass, pluginconfig, coreconfig):
if not issubclass(pluginclass, Plugin):
raise TypeError(
"Argument must be subclass of shepherd.plugin.Plugin")
self.coreconfig = coreconfig
self.hooks = SimpleNamespace() # My hooks
self.functions = SimpleNamespace() # My callable interface functions
self._name = pluginname
self._plugin = pluginclass(self, pluginconfig)
def register_hook(self, name):
setattr(self.hooks, name, Hook())
def register_function(self, func):
setattr(self.functions, func.__name__, InterfaceFunction(func))
@property
def other_plugins(self):
global plugin_functions
return plugin_functions
def attach_hook(self, pluginname, hookname, func):
_attach_hook(HookAttachment(pluginname, hookname, func))
# Add a job to the scheduler. By default each will be identified by the interface
# callable name, and so adding another job with the same callable will oevrride the first.
# Use the optional job_name to differentiate jobs with an extra string
def add_job(self, func, hour, minute, second=0, job_name=""):
for function_name, function in self.functions.__dict__.items():
if func == function.func:
# jobstring should canonically describe this job, to be retrieved
# after reboot later. Of the form:
# shepherd:pluginname:functionname:jobname
jobstring = "shepherd:"+self._name+":"+function_name+":"+job_name
_add_job(shepherd.scheduler.JobDescription(jobstring, hour, minute, second))
break
else:
raise Exception(
"Could not add job. Callable must first be registered as "
"a plugin interface with PluginInterface.register_function()")
"""
An interface to a Shepherd module, accessible by other modules.
All public methods in a module interface need to be threadsafe, as they will
be called by other modules (which generally run in a seperate thread)
"""
def find_plugins(plugin_names, plugin_dir=None):
"""
Looks for the list of plugin names supplied and returns their classes.
Will first try for plugin modules and packages locally located in ``shepherd.plugins``,
then for modules and packages prefixed ``shepherd_`` located in the supplied ``plugin_dir``
and lastly in the global import path.
Args:
plugin_names: List of plugin names to try and load
plugin_dir: optional search path
Returns:
Dict of plugin classes, with their names as keys
"""
plugin_classes = {}
for plugin_name in plugin_names:
# First look for core plugins, then the plugin_dir, then in the general import path
# for custom ones prefixed with "shepherd_"
try:
# mod = importlib.import_module("shepherd.plugins." + plugin_name)
mod = importlib.import_module('.'+plugin_name, "shepherd.plugins")
# TODO - ModuleNotFoundError is also triggered here if the plugin has a dependancy
# that can't be found
except ModuleNotFoundError:
try:
if plugin_dir:
if os.path.isdir(plugin_dir):
sys.path.append(plugin_dir)
mod = importlib.import_module("shepherd_" + plugin_name)
sys.path.remove(plugin_dir)
else:
raise PluginLoadError("plugin_dir is not a valid directory")
else:
mod = importlib.import_module("shepherd_" + plugin_name)
except ModuleNotFoundError:
raise PluginLoadError("Could not find plugin "+plugin_name)
# Scan imported module for Plugin subclass
def is_module_plugin(member, module=mod):
return (inspect.isclass(member) and member.__module__ ==
module.__name__ and issubclass(member, Plugin))
class_list = inspect.getmembers(mod, is_module_plugin)
if class_list:
if len(class_list) > 1:
log.warning(
F"Plugin module {mod.__name__} has more than one shepherd.Plugin subclass.")
_, plugin_classes[plugin_name] = class_list[0]
log.info(F"Loading plugin {plugin_classes[plugin_name].__name__}"
" from module {mod.__name__}")
else:
raise PluginLoadError("Imported shepherd plugin modules must contain a"
" subclass of shepherd.plugin.Plugin, such as"
" shepherd.plugin.SimplePlugin")
return plugin_classes