Thursday 15 June 2017

Writing plugins in Python

Standard
The advantage of dynamically typed language is that it's much easier to write some code that dynamically loads another code and uses it. The downside of lack of types is that it's harder to enforce the contract on the loaded code. There are ways around that and this post is about that. How to write a simple plugin system in your application, shown on an example.


MessengerAssistant

I just wrote a simple plugin driven Messenger Assistant. It's a bot that listens to your messages when you chat on Messenger and can execute commands based on that. It uses fbchat package from pypi. The simplest example would be:

- Command time, which gives prints to us current time.
In prototype version, it was just big if/elif statement to decide if the message is a command and if we need to reply to it. I wanted to do something more powerful and modular.

Plugin driven MessengerAssistant

I started off by doing a base for plugin, some template that every plugin has to follow to be compatible with my bot. If it was C# or Java, we would use interfaces which would enforce existence of some methods that we could later use. Python does not have an idea of interfaces, but it does provide module called abc(Abstract Base Classes).
Our AbstractPluginBase could look like this:

import abc


class AbstractPluginBase:
    __metaclass__ = abc.ABCMeta

    @abc.abstractmethod
    def __init__(self):
        pass

    @abc.abstractmethod
    def check_pattern(self, message): 
        # Check if we should handle this message
        return False

    @abc.abstractmethod
    def handle_message(self, message):
        # Handle the message
        return ""


abc works by using metaclass, class that sole purpose is to create classes(don't mix that with instances of classes). This use of abc works like interfaces, classes dervied from AbstractPluginBase must implement those three methods(__init__ exists by default so we won't see it implemented in concrete plugins, I added it so it is impossible to change initialization, which will be helpful later on).


Then in plugins/ python package(which means that there is __init__.py file inside) I wrote some example plugins like the time one you saw above

import time

from plugin_base import AbstractPluginBase


class TimePlugin(AbstractPluginBase):
    def check_pattern(self, message):
        if message == "time":
            return True
        else:
            return False

    def handle_message(self, message):
        return time.ctime()

As simple as that, the last missing piece is loading all the modules in plugins/ directory, for that I needed to bind all of the files inside plugins/ to plugins module. So __init__.py contains:

import glob
from os.path import dirname, basename, isfile

modules = glob.glob(dirname(__file__) + "/*.py")
__all__ = [basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]

Iterating over all files in directory, adding them to the package.

At this points we have plugins package that we can load from, last thing to do is to load all of the modules and get instances of them, in doing that we have to remember to only load subclasses of AbstractPluginBase, cause some plugins can use other classes for their implementations.

import inspect
import importlib


import plugins

class PluginLoader:
    @staticmethod
    def get_all_plugins():
        plugins_classes = []
        # Load all modules
        for module in [importlib.import_module("plugins." + x) for x in plugins.__all__]:
            # Get only subclasses of AbstractPluginBase
            plugins_classes += inspect.getmembers(module, PluginLoader.is_plugin)
        # Return instances of the classes
        return [(x[0], x[1]()) for x in plugins_classes]

    @staticmethod
    def is_plugin(object):
        return inspect.isclass(object) and issubclass(object, AbstractPluginBase) and object is not AbstractPluginBase

We had to use some reflection here, to check types of members of modules, so that we only get classes that satisfy three conditions: object is a class, object is a subclass of AbstractPluginBase and object is not AbstractPluginBase itself.
Then if we want to use the plugins, we just iterate over them and check if given plugin is able to handle given message.

for plugin in self.plugins:
    name, plugin_inst = plugin
    if plugin_inst.check_pattern(message):
        if self.debug:
            print("%s is handling the message" % name)
        output = plugin_inst.handle_message(message)
        self.send(send_id, output, is_user=is_user)

It wasn't so hard, was it? Python is really powerful when it comes to reflection and inspecting itself at runtime which gives us a lot of control when we know what we are doing.

I added some plugins like getting current value of my cryptocurrencies if I were to sell them at the current price at the exchange.

Full source code: https://github.com/hub2/Messenger-Assistant


0 comments:

Post a Comment