Plugin class architecture

One design pattern that I’ve been a big fan of for a long time, is the plugin architecture where new logic for importing or exporting can be added to a program by simply slotting in a file into a plugin directory.

Inspired by the way this works in Autodesk’s MotionBuilder and Max I began looking for how this could be accomplished in Python for my applications.

Thankfully by combining some import hook logic with python’s Metaclasses system allows for an almost trivial implementation of this design pattern, similar to pep 487

class _RegisteredMetaClass(type):
    """
    The metaclass will register the classes as they are read in
    """
    _REGISTERED = {} # Holds all the plugins as a dict of plugin type with a list of all the plugin classes
    def __init__(cls, name, bases, nmspc):
        currentTypesPlugin = cls._REGISTERED.get(cls.PLUGIN_TYPE, [])
        currentTypesPlugin.append(cls)
        cls._REGISTERED[cls.PLUGIN_TYPE] = currentTypesPlugin
class PluginBase(object):
    """
    This is our base metaclass for all the plugins, we will subclass for the different types to break
    up the logic, but they will all inherit from here.
    """
    
    __metaclass__ = _RegisteredMetaClass
    
    PLUGIN_TYPE = "" # The plugin type 
    PLUGIN_NAME = "" # The name of the plugin
    @classmethod
    def getPlugins(cls):
        """
        Class Method
        
        This will return back a list of all the plugins of the same plugin type
        
        retuns:
            list of Plugin objects for that type
        """
        if cls.PLUGIN_TYPE not in cls._REGISTERED.keys():
            return []
        return [plug for plug in cls._REGISTERED[cls.PLUGIN_TYPE] if issubclass(plug, cls) and plug.PLUGIN_NAME != ""]

If you are using Python 3.x rather than Python 2.x, then a class’s metaclass is defined in the class’s definition like this:

class PluginBase(object, metaclass=_RegisteredMetaClass):

Subclassing the PluginBase into Plugin Type Base, so that all IO plugins will inherit from the BaseIO class etc. This allows for an easy API for all the Plugin Types, for example, the BaseIO has methods for the file extensions used as well as entry points for the import and export logic, whereas the BaseAsset class has methods for working with Icons.

class BaseIO(PluginBase):
    """
    Base plugin for the Asset plugins
    """
    
    PLUGIN_TYPE = "IO"
    PLUGIN_NAME = ""
    FILE_EXTS = ()
    def __init__(self):
        super(BaseIO, self).__init__()
    
    @classmethod
    def fileExtensions(cls):
        """
        Virtual Method
        
        This will return back a list of file extensions
        """
        return NotImplemented("File Extensions not implemented.")
    
    @classmethod
    def importLogic(cls, data):
        """
        Virtual Method
        
        Import logic for loading a file
        """
        raise NotImplemented("Import not implemented")
    
    @classmethod
    def exportLogic(cls, data):
        """
        Virtual Method
        
        Export logic for saving a file
        """
        raise NotImplemented("Export not implemented")
class BaseAsset(PluginBase):
    """
    Base plugin for the Asset plugins
    """
    
    ASSET_TYPE = None
    PLUGIN_TYPE = "Asset"
    PLUGIN_NAME = ""
    ICON_PATH = r"...\icons\Unkown.png"
    def __init__(self):
        super(BaseAsset, self).__init__()
        
    @classmethod
    def icon(cls):
        """
        Return the icon for the asset. 
        """
        return cls.ICON_PATH

With this design, any class that is read into the interpreter will be registered, but having a single python file with all the plugins is unreasonable and restrictive. What would work best is to have a plugin folder structure with the different plugins as their own python files like this:

The best way to accomplish this is with a Python Import hook under plugins/__init__.py and it will walk through all the folders and subfolders to find all the python files and import them in.

import pkgutil
__all__ = []
for loader, module_name, is_pkg in pkgutil.walk_packages(__path__):
    __all__.append(module_name)
    module = loader.find_module(module_name).load_module(module_name)
    exec('%s = module' % (module_name))

As per my original design, adding a plugin is straightforward, subclassing the type and filling in the virtual methods and saving it as a python file in the plugin directory.

import pluginBase
class Csv(pluginBase.BaseIO):
    PLUGIN_NAME = "CSV IO"
    def __init__(self):
        super(Csv, self).__init__()
    @classmethod
    def fileExtentions(cls):
        return (("CSV File", ".csv"),)
    
    @classmethod
    def importLogic(cls, data):
        print("Importing Logic from CSV")
    
    @classmethod
    def exportLogic(cls, data):
        print("Exporting logic to CSV")

I went ahead and added 3 Asset plugins and 2 IO plugins into the demo code, and we can test it’s all working by grabbing the Plugin Base types and querying their plugins.

import pluginBase
# Print all the IO Plugins
print("IO Plugins")
for plugin in pluginBase.BaseIO.getPlugins():
    print("-> {} ({})".format(plugin.PLUGIN_NAME, plugin.fileExtentions()))
# Print all the Asset Plugins
print("Asset Plugins")
for plugin in pluginBase.BaseAsset.getPlugins():
    print("-> {} ({})".format(plugin.PLUGIN_NAME, plugin.icon()))

Hopefully this has shown how easy and powerful both the Python Hooks and Metaclasses can be when combined!

Happy Plugin-ing

– Geoff Samuel

File Downloads: