Completely rewritten plugin loader to accept absolute modules, or single file paths

This commit is contained in:
Joel Collins 2018-12-12 15:00:30 +00:00
parent 86cc29ee84
commit 6d464349b7
2 changed files with 90 additions and 129 deletions

View file

@ -1,3 +1,3 @@
__all__ = ['search_plugin_dirs', 'find_plugins', 'load_plugin', 'PluginMount', 'MicroscopePlugin'] __all__ = ['module_from_file', 'load_plugin_class', 'load_plugin_module', 'class_from_map', 'PluginMount', 'MicroscopePlugin']
from .loader import search_plugin_dirs, find_plugins, load_plugin, PluginMount, MicroscopePlugin from .loader import module_from_file, load_plugin_class, load_plugin_module, class_from_map, PluginMount, MicroscopePlugin

View file

@ -1,126 +1,83 @@
import importlib import importlib
import os import os
import warnings import inspect
import logging import logging
from openflexure_microscope.config import USER_CONFIG_DIR
MAIN_MODULE = '__init__' class bcolors:
HERE = os.path.abspath(os.path.dirname(__file__)) HEADER = '\033[95m'
DEFAULT_PLUGIN_PATH = os.path.join(HERE, 'default') OKBLUE = '\033[94m'
USER_PLUGIN_DIR = os.path.join(USER_CONFIG_DIR, "plugins") OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def search_plugin_dirs(plugin_paths, include_default=True): def module_from_file(plugin_path):
""" # Expand environment variables in path string
Search through, and load from, a list of plugin directories. plugin_path = os.path.expandvars(plugin_path)
# Expand user directory in path string
plugin_path = os.path.expanduser(plugin_path)
Args: # Check if the path is to a file
plugin_paths (list): List of strings of plugin directories. if not os.path.isfile(plugin_path):
include_default (bool): Also load plugins from the module default directory (DEFAULT_PLUGIN_PATH, USER_PLUGIN_DIR) logging.warning(bcolors.FAIL + "No valid plugin found at {}.".format(plugin_path) + bcolors.ENDC)
""" return None, None, None
global DEFAULT_PLUGIN_PATH, USER_PLUGIN_DIR
if not os.path.exists(USER_PLUGIN_DIR): # If user config file already exists else:
os.makedirs(USER_PLUGIN_DIR) # Get name of plugin from the file
plugin_name = os.path.splitext(os.path.basename(plugin_path))[0]
if include_default: # If including default plugins plugin_spec = importlib.util.spec_from_file_location(plugin_name, plugin_path)
plugin_paths.append(DEFAULT_PLUGIN_PATH) # Add default directory to the search paths plugin_module = importlib.util.module_from_spec(plugin_spec)
plugin_paths.append(USER_PLUGIN_DIR) # Add user directory to the search paths
logging.debug(plugin_paths) return plugin_spec, plugin_module, plugin_name
plugins = [] # List of loaded plugins
for plugin_dir in plugin_paths: # For each plugin directory
logging.debug("Searching {}".format(plugin_dir))
plugins.extend(find_plugins(plugin_dir)) # Find plugin folders, and load into list
return plugins
def find_plugins_legacy(plugin_dir): def load_plugin_module(plugin_path):
""" # First, try importing from standard modules
Find all plugins residing within a directory try:
plugin_module = importlib.import_module(plugin_path)
except ModuleNotFoundError:
plugin_spec, plugin_module, plugin_name = module_from_file(plugin_path)
Args: # If a valid plugin was found
plugin_dir (str): String of directory to be searched if plugin_spec and plugin_module:
""" # Execute the module, so we have access to it
plugins = [] plugin_spec.loader.exec_module(plugin_module)
plugins_folders = os.listdir(plugin_dir) else:
plugin_name = plugin_path.split('.')[-1]
loader_details = ( return plugin_module, plugin_name
importlib.machinery.SourceFileLoader,
importlib.machinery.SOURCE_SUFFIXES
)
for i in plugins_folders:
plugin_folder = os.path.join(plugin_dir, i)
logging.info(plugin_folder)
if not os.path.isdir(plugin_folder): # If plugin folder doesn't exist
continue # Skip this iteration
if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): # If no __init__ file in plugin folder
continue # Skip this iteration
module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE)
plugins.append(module_spec)
return plugins
def find_plugins(plugin_dir): def load_plugin_class(plugin_path, plugin_class_name):
""" plugin_module, plugin_name = load_plugin_module(plugin_path)
Find all plugins residing within a directory if plugin_module:
# Now try to extract the class
Args: try:
plugin_dir (str): String of directory to be searched plugin_class = getattr(plugin_module, plugin_class_name)
""" except AttributeError:
plugins = [] logging.warning(bcolors.FAIL + "Class {} does not exist in plugin {}. Skipping.".format(plugin_class_name, plugin_path) + bcolors.ENDC)
plugins_folders = os.listdir(plugin_dir) return None, None
else:
loader_details = ( return plugin_class, plugin_name
importlib.machinery.SourceFileLoader, else:
importlib.machinery.SOURCE_SUFFIXES return None, None
)
for i in plugins_folders:
plugin_folder = os.path.join(plugin_dir, i)
logging.info(plugin_folder)
if not os.path.isdir(plugin_folder): # If plugin folder doesn't exist
continue # Skip this iteration
if not MAIN_MODULE + '.py' in os.listdir(plugin_folder): # If no __init__ file in plugin folder
continue # Skip this iteration
module_spec = importlib.machinery.FileFinder(plugin_folder, loader_details).find_spec(MAIN_MODULE)
plugins.append(module_spec)
return plugins
def load_plugin(module_spec): def class_from_map(plugin_map):
""" plugin_arr = plugin_map.split(':')
Load a source file from a given spec.
Args: if not len(plugin_arr) == 2:
module_spec: Module spec of module to be returned logging.warning(bcolors.WARNING + "Malformed plugin map {}. Skipping.".format(plugin_map) + bcolors.ENDC)
""" return None, None
module = importlib.util.module_from_spec(module_spec) else:
module_spec.loader.exec_module(module) return load_plugin_class(*plugin_arr)
return module
def load_plugin_legacy(module_spec):
"""
Load a source file from a given spec.
Args:
module_spec: Module spec of module to be returned
"""
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
return module
class PluginMount(object): class PluginMount(object):
""" """
A mount-point for all loaded plugins. Attaches to a Microscope object. A mount-point for all loaded plugins. Attaches to a Microscope object.
@ -130,44 +87,45 @@ class PluginMount(object):
""" """
def __init__(self, parent): def __init__(self, parent):
self.parent = parent self.parent = parent
self.plugins = []
print("Creating plugin mount") print("Creating plugin mount")
def attach(self, plugin_module): @property
def members(self):
plugin_array = []
for obj_name in dir(self):
if not obj_name == "plugins" and not obj_name[:2] == '__':
obj = getattr(self, obj_name)
if isinstance(obj, MicroscopePlugin):
plugin_members = [member for member in inspect.getmembers(obj) if not member[0][:2] == '__']
plugin_info = (obj_name, plugin_members)
plugin_array.append(plugin_info)
return plugin_array
def attach(self, plugin_map):
""" """
Attach a MicroscopePlugin instance to the plugin mount. Attach a MicroscopePlugin instance to the plugin mount.
Args: Args:
plugin_module: A loaded module to be attached. Module can be loaded using :py:meth:`openflexure_microscope.plugins.load_plugin` plugin_map (str): A plugin map describing the file or module to load a MicroscopePlugin child from. Maps should be in the format 'module.to.load:ClassName' or '/path/to/file:ClassName'.
""" """
print("LOADING MODULE {}".format(plugin_module.__name__)) plugin_class, plugin_name = class_from_map(plugin_map)
if hasattr(plugin_module, 'PLUGINS') and isinstance(plugin_module.PLUGINS, dict): if plugin_class and plugin_name:
plugin_object = plugin_class()
for plugin_name, plugin_class in plugin_module.PLUGINS.items(): if hasattr(self, plugin_name): # If a plugin with the same name is already attached.
logging.warning(bcolors.WARNING + "A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_map) + bcolors.ENDC)
plugin_object = plugin_class() elif isinstance(plugin_object, MicroscopePlugin): # If plugin_object is an instance of MicroscopePlugin
if hasattr(self, plugin_name): # Attach plugin_object to the plugin mount
warnings.warn("A plugin named {} has already been loaded. Skipping {}.".format(plugin_name, plugin_class)) setattr(self, plugin_name, plugin_object)
else: self.plugins.append((plugin_name, plugin_object))
setattr(self, plugin_name, plugin_object)
# Grant plugin access to the hardware # Grant plugin access to the hardware
assert(isinstance(plugin_object, MicroscopePlugin)) plugin_object.microscope = self.parent
plugin_object.microscope = self.parent
print("Adding plugin: {}".format(plugin_name)) logging.info(bcolors.OKGREEN + "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + bcolors.ENDC)
else:
warnings.warn("No valid PLUGINS dictionary found in {}".format(plugin_module))
class PluginGroup():
"""
A class used to group plugin methods within a PluginMount.
Currently useless aside from creating a namespace in which plugin methods will reside.
"""
def __init__(self):
pass
class MicroscopePlugin(): class MicroscopePlugin():
@ -177,5 +135,8 @@ class MicroscopePlugin():
Initially only defines an empty object for microscope. All plugins Initially only defines an empty object for microscope. All plugins
must be an instance of this class to successfully attach to PluginMount. must be an instance of this class to successfully attach to PluginMount.
""" """
api_views = {} # Initially empty dictionary of API views associated with the plugin
def __init__(self): def __init__(self):
self.microscope = None #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object self.microscope = None #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object