summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2009-11-19 09:19:05 +0200
committerLars Wirzenius <liw@liw.fi>2009-11-19 09:19:05 +0200
commit56aa1e7235ea5fe65e506e6697d4c47a9c7b8743 (patch)
treee8e3d341a45daf420962e1a56ae8fb3120a8e686
parentfdf76c376d665c5b746cbddc93346f4306c34893 (diff)
downloadobnam-56aa1e7235ea5fe65e506e6697d4c47a9c7b8743.tar.gz
Copied over a plugin manager from Dimbola.
-rw-r--r--obnamlib/pluginmgr.py245
-rw-r--r--obnamlib/pluginmgr_tests.py148
-rw-r--r--test-plugins/aaa_hello_plugin.py8
-rw-r--r--test-plugins/hello_plugin.py11
-rw-r--r--test-plugins/oldhello_plugin.py9
-rw-r--r--test-plugins/wrongversion_plugin.py12
-rw-r--r--without-tests5
7 files changed, 438 insertions, 0 deletions
diff --git a/obnamlib/pluginmgr.py b/obnamlib/pluginmgr.py
new file mode 100644
index 00000000..e10be85d
--- /dev/null
+++ b/obnamlib/pluginmgr.py
@@ -0,0 +1,245 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+'''A generic plugin manager.
+
+The plugin manager finds files with plugins and loads them. It looks
+for plugins in a number of locations specified by the caller. To add
+a plugin to be loaded, it is enough to put it in one of the locations,
+and name it *_plugin.py. (The naming convention is to allow having
+other modules as well, such as unit tests, in the same locations.)
+
+'''
+
+
+import imp
+import inspect
+import os
+
+
+class Plugin(object):
+
+ '''Base class for plugins.
+
+ A plugin MUST NOT have any side effects when it is instantiated.
+ This is necessary so that it can be safely loaded by unit tests,
+ and so that a user interface can allow the user to disable it,
+ even if it is installed, with no ill effects. Any side effects
+ that would normally happen should occur in the enable() method,
+ and be undone by the disable() method. These methods must be
+ callable any number of times.
+
+ The subclass MAY define the following attributes:
+
+ * name
+ * description
+ * version
+ * required_application_version
+
+ name is the user-visible identifier for the plugin. It defaults
+ to the plugin's classname.
+
+ description is the user-visible description of the plugin. It may
+ be arbitrarily long, and can use pango markup language. Defaults
+ to the empty string.
+
+ version is the plugin version. Defaults to '0.0.0'. It MUST be a
+ sequence of integers separated by periods. If several plugins with
+ the same name are found, the newest version is used. Versions are
+ compared integer by integer, starting with the first one, and a
+ missing integer treated as a zero. If two plugins have the same
+ version, either might be used.
+
+ required_application_version gives the version of the minimal
+ application version the plugin is written for. The first integer
+ must match exactly: if the application is version 2.3.4, the
+ plugin's required_application_version must be at least 2 and
+ at most 2.3.4 to be loaded. Defaults to 0.
+
+ '''
+
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ @property
+ def description(self):
+ return ''
+
+ @property
+ def version(self):
+ return '0.0.0'
+
+ @property
+ def required_application_version(self):
+ return '0.0.0'
+
+ def enable(self):
+ '''Enable the plugin.'''
+ raise Exception('Unimplemented')
+
+ def disable(self):
+ '''Disable the plugin.'''
+ raise Exception('Unimplemented')
+
+ def enable_signal(self, obj, signal_name, callback):
+ '''Connect to a GObject signal.
+
+ This will remember the id so that disable_signals may do its stuff.
+
+ '''
+
+ if not hasattr(self, 'gobject_connect_ids'):
+ self.gobject_connect_ids = list()
+ conn_id = obj.connect(signal_name, callback)
+ self.gobject_connect_ids.append((obj, conn_id))
+
+ def disable_signals(self):
+ '''Disable all signals enabled with enable_signal.'''
+ if hasattr(self, 'gobject_connect_ids'):
+ for obj, conn_id in self.gobject_connect_ids:
+ obj.disconnect(conn_id)
+ del self.gobject_connect_ids[:]
+
+
+
+class PluginManager(object):
+
+ '''Manage plugins.
+
+ This class finds and loads plugins, and keeps a list of them that
+ can be accessed in various ways.
+
+ The locations are set via the locations attribute, which is a list.
+
+ When a plugin is loaded, an instance of its class is created. This
+ instance is initialized using normal and keyword arguments specified
+ in the plugin manager attributes plugin_arguments and
+ plugin_keyword_arguments.
+
+ The version of the application using the plugin manager is set via
+ the application_version attribute. This defaults to '0.0.0'.
+
+ '''
+
+ suffix = '_plugin.py'
+
+ def __init__(self):
+ self.locations = []
+ self._plugins = None
+ self._plugin_files = None
+ self.plugin_arguments = []
+ self.plugin_keyword_arguments = {}
+ self.application_version = '0.0.0'
+
+ @property
+ def plugin_files(self):
+ if self._plugin_files is None:
+ self._plugin_files = self.find_plugin_files()
+ return self._plugin_files
+
+ @property
+ def plugins(self):
+ if self._plugins is None:
+ self._plugins = self.load_plugins()
+ return self._plugins
+
+ def __getitem__(self, name):
+ for plugin in self.plugins:
+ if plugin.name == name:
+ return plugin
+ raise KeyError('Plugin %s is not known' % name)
+
+ def find_plugin_files(self):
+ '''Find files that may contain plugins.
+
+ This finds all files named *_plugin.py in all locations.
+ The returned list is sorted.
+
+ '''
+
+ pathnames = []
+
+ for location in self.locations:
+ try:
+ basenames = os.listdir(location)
+ except os.error:
+ continue
+ for basename in basenames:
+ s = os.path.join(location, basename)
+ if s.endswith(self.suffix) and os.path.exists(s):
+ pathnames.append(s)
+
+ return sorted(pathnames)
+
+ def load_plugins(self):
+ '''Load plugins from all plugin files.'''
+
+ plugins = dict()
+
+ for pathname in self.plugin_files:
+ for plugin in self.load_plugin_file(pathname):
+ if plugin.name in plugins:
+ p = plugins[plugin.name]
+ if self.is_older(p.version, plugin.version):
+ plugins[plugin.name] = plugin
+ else:
+ plugins[plugin.name] = plugin
+
+ return plugins.values()
+
+ def is_older(self, version1, version2):
+ '''Is version1 older than version2?'''
+ return self.parse_version(version1) < self.parse_version(version2)
+
+ def load_plugin_file(self, pathname):
+ '''Return plugin classes in a plugin file.'''
+
+ name, ext = os.path.splitext(os.path.basename(pathname))
+ f = file(pathname, 'r')
+ module = imp.load_module(name, f, pathname,
+ ('.py', 'r', imp.PY_SOURCE))
+ f.close()
+
+ plugins = []
+ for dummy, member in inspect.getmembers(module, inspect.isclass):
+ if issubclass(member, Plugin):
+ p = member(*self.plugin_arguments,
+ **self.plugin_keyword_arguments)
+ if self.compatible_version(p.required_application_version):
+ plugins.append(p)
+
+ return plugins
+
+ def compatible_version(self, required_application_version):
+ '''Check that the plugin is version-compatible with the application.
+
+ This checks the plugin's required_application_version against
+ the declared application version and returns True if they are
+ compatible, and False if not.
+
+ '''
+
+ req = self.parse_version(required_application_version)
+ app = self.parse_version(self.application_version)
+
+ return app[0] == req[0] and app >= req
+
+ def parse_version(self, version):
+ '''Parse a string represenation of a version into list of ints.'''
+
+ return [int(s) for s in version.split('.')]
+
diff --git a/obnamlib/pluginmgr_tests.py b/obnamlib/pluginmgr_tests.py
new file mode 100644
index 00000000..283a839d
--- /dev/null
+++ b/obnamlib/pluginmgr_tests.py
@@ -0,0 +1,148 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+from pluginmgr import Plugin, PluginManager
+
+
+class MockGObject(object):
+
+ def connect(self, *args):
+ self.connect_args = args
+ return 42
+
+ def disconnect(self, *args):
+ self.disconnect_args = args
+
+
+class PluginTests(unittest.TestCase):
+
+ def setUp(self):
+ self.plugin = Plugin()
+
+ def test_name_is_class_name(self):
+ self.assertEqual(self.plugin.name, 'Plugin')
+
+ def test_description_is_empty_string(self):
+ self.assertEqual(self.plugin.description, '')
+
+ def test_version_is_zeroes(self):
+ self.assertEqual(self.plugin.version, '0.0.0')
+
+ def test_required_application_version_is_zeroes(self):
+ self.assertEqual(self.plugin.required_application_version, '0.0.0')
+
+ def test_enable_raises_exception(self):
+ self.assertRaises(Exception, self.plugin.enable)
+
+ def test_disable_raises_exception(self):
+ self.assertRaises(Exception, self.plugin.disable)
+
+ def test_enables_signal(self):
+ obj = MockGObject()
+ self.plugin.enable_signal(obj, 'signal_name', 'callback')
+ self.assertEqual(obj.connect_args, ('signal_name', 'callback'))
+
+ def test_disables_signals(self):
+ obj = MockGObject()
+ self.plugin.enable_signal(obj, 'signal_name', 'callback')
+ self.plugin.disable_signals()
+ self.assertEqual(obj.disconnect_args, (42,))
+
+
+class PluginManagerInitialStateTests(unittest.TestCase):
+
+ def setUp(self):
+ self.pm = PluginManager()
+
+ def test_locations_is_empty_list(self):
+ self.assertEqual(self.pm.locations, [])
+
+ def test_plugins_is_empty_list(self):
+ self.assertEqual(self.pm.plugins, [])
+
+ def test_application_version_is_zeroes(self):
+ self.assertEqual(self.pm.application_version, '0.0.0')
+
+ def test_plugin_files_is_empty(self):
+ self.assertEqual(self.pm.plugin_files, [])
+
+ def test_plugin_arguments_is_empty(self):
+ self.assertEqual(self.pm.plugin_arguments, [])
+
+ def test_plugin_keyword_arguments_is_empty(self):
+ self.assertEqual(self.pm.plugin_keyword_arguments, {})
+
+
+class PluginManagerTests(unittest.TestCase):
+
+ def setUp(self):
+ self.pm = PluginManager()
+ self.pm.locations = ['test-plugins', 'not-exist']
+ self.pm.plugin_arguments = ('fooarg',)
+ self.pm.plugin_keyword_arguments = { 'bar': 'bararg' }
+
+ self.files = sorted(['test-plugins/hello_plugin.py',
+ 'test-plugins/aaa_hello_plugin.py',
+ 'test-plugins/oldhello_plugin.py',
+ 'test-plugins/wrongversion_plugin.py'])
+
+ def test_finds_the_right_plugin_files(self):
+ self.assertEqual(self.pm.find_plugin_files(), self.files)
+
+ def test_plugin_files_attribute_implicitly_searches(self):
+ self.assertEqual(self.pm.plugin_files, self.files)
+
+ def test_loads_hello_plugin(self):
+ plugins = self.pm.load_plugins()
+ self.assertEqual(len(plugins), 1)
+ self.assertEqual(plugins[0].name, 'Hello')
+
+ def test_plugins_attribute_implicitly_searches(self):
+ self.assertEqual(len(self.pm.plugins), 1)
+ self.assertEqual(self.pm.plugins[0].name, 'Hello')
+
+ def test_initializes_hello_with_correct_args(self):
+ plugin = self.pm['Hello']
+ self.assertEqual(plugin.foo, 'fooarg')
+ self.assertEqual(plugin.bar, 'bararg')
+
+ def test_raises_keyerror_for_unknown_plugin(self):
+ self.assertRaises(KeyError, self.pm.__getitem__, 'Hithere')
+
+
+class PluginManagerCompatibleApplicationVersionTests(unittest.TestCase):
+
+ def setUp(self):
+ self.pm = PluginManager()
+ self.pm.application_version = '1.2.3'
+
+ def test_rejects_zero(self):
+ self.assertFalse(self.pm.compatible_version('0'))
+
+ def test_rejects_two(self):
+ self.assertFalse(self.pm.compatible_version('2'))
+
+ def test_rejects_one_two_four(self):
+ self.assertFalse(self.pm.compatible_version('1.2.4'))
+
+ def test_accepts_one(self):
+ self.assert_(self.pm.compatible_version('1'))
+
+ def test_accepts_one_two_three(self):
+ self.assert_(self.pm.compatible_version('1.2.3'))
+
diff --git a/test-plugins/aaa_hello_plugin.py b/test-plugins/aaa_hello_plugin.py
new file mode 100644
index 00000000..f7ca7e9b
--- /dev/null
+++ b/test-plugins/aaa_hello_plugin.py
@@ -0,0 +1,8 @@
+import pluginmgr
+
+class Hello(pluginmgr.Plugin):
+
+ def __init__(self, foo, bar=None):
+ self.foo = foo
+ self.bar = bar
+
diff --git a/test-plugins/hello_plugin.py b/test-plugins/hello_plugin.py
new file mode 100644
index 00000000..7f0472c8
--- /dev/null
+++ b/test-plugins/hello_plugin.py
@@ -0,0 +1,11 @@
+import pluginmgr
+
+class Hello(pluginmgr.Plugin):
+
+ def __init__(self, foo, bar=None):
+ self.foo = foo
+ self.bar = bar
+
+ @property
+ def version(self):
+ return '0.0.1'
diff --git a/test-plugins/oldhello_plugin.py b/test-plugins/oldhello_plugin.py
new file mode 100644
index 00000000..47b3b674
--- /dev/null
+++ b/test-plugins/oldhello_plugin.py
@@ -0,0 +1,9 @@
+import pluginmgr
+
+class Hello(pluginmgr.Plugin):
+
+ def __init__(self, foo, bar=None):
+ self.foo = foo
+ self.bar = bar
+
+
diff --git a/test-plugins/wrongversion_plugin.py b/test-plugins/wrongversion_plugin.py
new file mode 100644
index 00000000..4ecc6aac
--- /dev/null
+++ b/test-plugins/wrongversion_plugin.py
@@ -0,0 +1,12 @@
+# This is a test plugin that requires a newer application version than
+# what the test harness specifies.
+
+import pluginmgr
+
+class WrongVersion(pluginmgr.Plugin):
+
+ required_application_version = '9999.9.9'
+
+ def __init__(self, *args, **kwargs):
+ pass
+
diff --git a/without-tests b/without-tests
index 3024c259..63f81967 100644
--- a/without-tests
+++ b/without-tests
@@ -3,3 +3,8 @@
./obnamlib/status.py
./obnamlib/vfs.py
./obnamlib/vfs_sftp.py
+./test-plugins/hello_plugin.py
+./test-plugins/oldhello_plugin.py
+./test-plugins/aaa_hello_plugin.py
+./test-plugins/wrongversion_plugin.py
+