Saturday, December 20, 2008

Plugins - part 5: Activation and Deactivation

(Note: the code indentation may appear to be wrong due to some blogspot's quirks...)

While plugins are a great way to extend the functionality of an application, sometimes it makes sense to limit the number of available features, based on a user's preference. For example, gedit, the official text editor of the Gnome environment, offers the possibility to activate or deactivate a plugin.
[link to image of activated plugins for gedit]
In this post, using the class-based plugin approach, I will explain how to add the possibility to activate or deactivate a given plugin. Furthermore, I will show how to use this capability to dynamically load new plugins.

Starting from the beginning...

Our starting point will be the following modified core application (calculator.py):
import re

from plugins.base import OPERATORS, init_plugins, activate, deactivate

class literal_token(object):
def __init__(self, value):
self.value = value
def nud(self):
return self.value

class end_token(object):
lbp = 0

def tokenize(program):
for number, operator in re.findall("\s*(?:(\d+)|(\*\*|.))", program):
if number:
yield literal_token(int(number))
elif operator in OPERATORS:
yield OPERATORS[operator]()
else:
raise SyntaxError("unknown operator: %r" % operator)
yield end_token()

def expression(rbp=0):
global token
t = token
token = next()
left = t.nud()
while rbp < token.lbp:
t = token
token = next()
left = t.led(left)
return left

def calculate(program):
global token, next
next = tokenize(program).next
token = next()
return expression()

if __name__ == "__main__":
init_plugins(expression)
assert calculate("+1") == 1
assert calculate("-1") == -1
assert calculate("10") == 10
assert calculate("1+2") == 3
assert calculate("1+2+3") == 6
assert calculate("1+2-3") == 0
assert calculate("1+2*3") == 7
assert calculate("1*2+3") == 5
assert calculate("6*2/3") == 4

# "**" has not been activated at the start in base.py
try:
assert calculate("2**3") == 8
except SyntaxError:
print "Correcting error..."
activate("**")
assert calculate("2*2**3") == 16

deactivate('+')
try:
assert calculate("1+2") == 3
except SyntaxError:
activate('+')
assert calculate("1+2") == 3

print "Done!"


The new features are indicated by different colours. In blue, we have two new functions imported to either activate or deactivate a given plugin. When the application is started, exponentiation is disabled - this can only be seen by looking at the modified version of base.py. When a disabled plugin is called, a SyntaxError already present in the old version) is raised and we activate the plugin.

To make this possible, we need to modify base.py. Before showing the new version, here's the result of running the above code:
Activating +
Activating -
Activating *
Activating /
Correcting error...
Activating **
Deactivating +
Activating +
Done!


And here's the new version of base.py:

import os
import sys

OPERATORS = {}

# We simulate a configuration file that would be based on a user's preference
# as to which plugin should be activated by default
# We will leave one symbol "**" out of the list as a test.
preferences = ['+', '-', '*', '/']

# We also keep track of all available plugins, activated or not
all_plugins = {}

class Plugin(object):
'''base class for all plugins'''

def activate(self):
'''activate a given plugin'''
if self.symbol not in OPERATORS:
print "Activating %s" % self.symbol
OPERATORS[self.symbol] = self.__class__
if self.symbol not in all_plugins:
all_plugins[self.symbol] = self.__class__

def deactivate(self):
'''deactivate a given plugin'''
print "Deactivating %s" % self.symbol
if self.symbol in OPERATORS:
del OPERATORS[self.symbol]

def activate(symbol):
'''activate a given plugin based on its symbol'''
if symbol in OPERATORS:
return
all_plugins[symbol]().activate()

def deactivate(symbol):
'''deactivate a given plugin, based on its symbol'''
if symbol not in OPERATORS:
return
all_plugins[symbol]().deactivate()

def init_plugins(expression):
'''simple plugin initializer
'''
find_plugins(expression)
register_plugins()

def find_plugins(expression):
'''find all files in the plugin directory and imports them'''
plugin_dir = os.path.dirname(os.path.realpath(__file__))
plugin_files = [x[:-3] for x in os.listdir(plugin_dir) if x.endswith(".py")]
sys.path.insert(0, plugin_dir)
for plugin in plugin_files:
mod = __import__(plugin)
mod.expression = expression

def register_plugins():
'''Register all class based plugins.

Uses the fact that a class knows about all of its subclasses
to automatically initialize the relevant plugins
'''
for plugin in Plugin.__subclasses__():
# only register plugins according to user's preferences
if plugin.symbol in preferences:
plugin().activate()
else: # record its existence
all_plugins[plugin.symbol] = plugin

Changes from the old version are indicated in blue (with corresponding comments in green). Note that we did not change a single line of code for the actual plugins! We did use the same names (activate and deactivate) both for a function and a class method. This should probably be avoided in a larger application. In this example, the code is short enough that it should not create too much confusion. In a real application we would also give the possibility of changing the user's preferences, storing the information in some configuration file.

Dynamic activation

Now that we now how to activate and deactivate a plugin, it might be useful to consider dynamic activation of an external plugin, not located in the normal plugins directory. For example, consider the following plugin (located in op_3.py):


from plugins.base import Plugin

class operator_mod_token(Plugin):
symbol = '%'
lbp = 10
def nud(self):
return expression(100)
def led(self, left):
return left % expression(10)

This file is located in subdirectory "external" which is at the same level as "plugins" in our sample code. To invoke this plugin from our base application, we need to add the following code to calculator.py:

if __name__ == "__main__":
#...

# Simulating dynamic external plugin initialization
external_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)),
'external')
sys.path.insert(0, external_dir)
mod = __import__('op_3')
mod.expression = expression
# register this plugin using our default method
register_plugins()
# Since it is not activated by default, we need to do it explictly
activate('%')
assert calculate("7%2") == 1

print "Done!"

Note that we also need to import register_plugins() from base.py to make this work.

That's it! If you get the code from the py-fun repository, you can try it out yourself.

2 comments:

Anonymous said...

I'm enjoying this series of articles; plugins are something I had been wondering how to do in Python. One thing though: in English désactiver is 'to deactivate' (not 'to desactivate'.)

André Roberge said...

Thanks Matthew!