An easy to extend plugin framework
In a number of my projects I have made several iterations of developing a plugin framework - that is, a way that functionality can be extended easily (by me, or anyone else) without having to explicitly editing the main code.An example might be a game where the user has to manage a number of different types of resources produced by different types of buildings, and with specialist people/aliens/trolls etc staffing those buildings and using those resources. With an efficient plugin system, it is relatively easy to imagine the base game defining a starting set of buildings, resources, and types of workers/aliens etc, and then be able to extend the game adding new buildings, new resources etc by simply adding a new plugin, and without anyone editing the main game.
A plugin system has to have the following characteristics :
- Automatic Discovery : Newly added plugins should be able to be automatically discoverable - i.e. the application should be able to find new plugins easily without any user intervention
- Clear Functionality : It should be obvious to the application what "type" of functionality the plugin adds (using the game example above does the plugin add new resources, new buildings or new workers - or all 3 ?).
- Is there a simple way for the user to use the plugin once it is added; for instance are plugins added to existing menus, or do they create obvious new menus etc.
Source Code
Source code to accompany this article is published on GitHub : plugin framework. This includes a package which encapsulates the code within a simple to use class, an example very simple application (showing how to use the framework), and a very simple skeleton plugin, showing the very simple requirements that any plugin should implement to work with this framework.1 -Automatic Discovery
This is actually three parts; can we find the code that forms the plugin, can we load this code (a python module or package), and can we identify which parts of this loaded code are actually the plugins and which are supporting code only being used by the plugin.Finding the python code
Perhaps unsurprisingly, this is the simplest problem to address - the application can keep all of the plugins in a standard directory (or maybe two - a system wide and user specific directory) :import sys import os def get_plugin_dirs( app_name ): """Return a list of plugin directories for this application or user Add all possible plugin paths to sys.path - these could be <app_path>/plugins and ~/<app_name>/plugins Only paths which exist are added to sys.path : It is entirely possible for nothing to be added. return the paths which were added to sys.path """ # Construct the directories into a list plugindirs = [os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "plugins"), os.path.expanduser("~/.{}/plugins".format(app_name) )] # Remove any non-existant directories plugindirs = [path for path in plugindirs if os.path.isdir(path)] sys.path = plugindirs + sys.path return plugindirs
Note
The get_plugin_dirs function presented above relies heavily on the os.path library, since this is the most portable way to ensure that the application correctly constructs valid file paths etc.We have a list of zero or more directories which may contain plugin code, so - lets identify code in those directories.
In Python code could exist as either :
- An uncompiled python file, with the `.py` extension.
- A compiled python file, with the `.pyc` extension
- A C extension, with `.so` extension (or similar)
Plugins written in C ?
If you are adept enough to write an extension in C which you want to use as a plugin, then you can also easily write one or more wrappers in Python around you C extension code so that it complies with our framework - more on that later.import imp import importlib def identify_modules(dir_list): """Generate a list of valid modules or packages to be imported param: dir_list : A list of directories to search in return: A list of modules/package names which might be importable """ # imp.get_suffixes returns a list of tuples : (<suffix>, <mode>, <type>) suff_list = [s[0] for s in imp.get_suffixes() if s[2] in [imp.PY_SOURCE, imp.PY_COMPILED]] # By using a set we easily remove duplicated names - e.g. file.py and file.pyc candidates = set() # Look through all the directories in the dir_list for dir in dir_list: # Get the content of each dir - don't need os.walk dir_content = os.listdir(dir) # Look through each name in the directory for file in dir_content: # Does the file have a valid suffix for a python file if os.path.isfile(os.path.join(dir,file)) and os.path.splitext(file)[1] in suff_list: candidates.add(os.path.splitext(file)[0]) # Is the file a package (i.e. a directory containing a __init__.py or __init__.pyc file if os.path.isdir(os.path.join(dir, file)) and any(os.path.exists(os.path.join(dir, file, f)) for f in ["__init__"+s for s in suff_list]): candidates.add(os.path.splitext(file)[0]) return candidatesIn the final discovery step - we need to see if any of the identified files actually implement a plugin, and for this step we can use a hidden gem of the Python Standard Library - the inspect library. The inspect provides functionality to look inside python modules and classes, including ways to list the classes within modules, and methods in classes (and a lot more besides). We are also going to make use of a key feature of Object Oriented programming - inheritance. We can define a basic PluginBase class, and use the inspect library to look at each of out candidate modules to find a class which inherits from the plugin class. In order to comply with our framework, the classes which implement our plugins must inherit from PluginBase.
Location of PluginBase
Currently we are presenting our framework as a set of functions - without identifying a module etc. If our plugin classes are going to inherit from PluginBase, then our framework, and especially PluginBase will need to exists in a place where it can be easily imported by our plugin modules. This is achieved by having the PluginBase class defined in a top level module, or a module in a top level package. (A top level module/package is one that exists directly under one of the entries in sys.path).import inspect import importlib class PluginBase(object): @classmethod def register(cls_): """Must be implemented by the actual plugin class Must return a basic informational string about the plugin """ raise NotImplemented("Register method not implemented in {}".format(cls_)) def find_plugin_classes(module_list): """Return a list of classes which inherit from PluginBase param: module_list: a list of valid modules - from identify_modules return : A dictionary of classes, which inherit from PluginBase, and implement the register method The class is the key in the dictionary, and the value is the returned string from the register method """ cls_dict = {} for mod_name in module_list: m = importlib.import_module(mod_name) for name, cls_ in inspect.getmembers(m, inspect.isclass): if issubclass(cls_, PluginBase): try: info = cls_.register() except NotImplemented: continue else: cls_dict[cls_] = info return cls_dictAnd there we have it - the basis of plugin characteristic #1- That the plugin is automatically discoverable. Using the code above - all that the Plugin implementation needs to do is to be in a module which exists in one of the two plugin directories, be a class which inherit from the PluginBase class, and implement a sensible register method.
2 - Clear Functionality
The clear functionality characteristic is incredibly easy to implement, again using inheritance. Your application will have a number of base classes which define the basic functionality that each element of your game will implement, so just ensure that your plugin classes inherit from one of these classes.import collections def categorise_plugins(cls_dict, base_classes): """Split the cls_dict into one or more lists depending on which base_class the plugin class inherits from""" categorise = collections.defaultdict(lambda x: {}) for base in base_classes: for cls_ in cls_dict: if issubclass(cls_,base): categorise[base][cls_] = cls_dict[cls_] return categoriseWe can put all of this together into a useful helper class for the loading and unloading of the plugin functionality - see plugin_framework on GitHub for the full implementation, and a demonstration simple application.
3 - Simple to Use
How your application makes the plug-in simple to use and accessible really does depend on your application, but as promised here are some pointers :- The register() method on the plugin-class could be used to return information on which menus etc this plugin should appear - or even if a new menus, toolboxes etc should be created to allow access to this plugin.
- In most cases the plugin class should also allow itself to be instantiated, and each instance may well be in a different state at any given time. The class therefore will need to implement methods to allow the the application to use those instances, to change their state etc.