Merge lp:~zyga/lava-core/plugin-loader-respin into lp:lava-core

Proposed by Zygmunt Krynicki
Status: Merged
Approved by: Michael Hudson-Doyle
Approved revision: no longer in the source branch.
Merged at revision: 9
Proposed branch: lp:~zyga/lava-core/plugin-loader-respin
Merge into: lp:lava-core
Prerequisite: lp:~zyga/lava-core/history-and-logging
Diff against target: 455 lines (+441/-0)
3 files modified
lava/core/errors.py (+38/-0)
lava/core/plugins.py (+131/-0)
lava/core/tests/test_plugins.py (+272/-0)
To merge this branch: bzr merge lp:~zyga/lava-core/plugin-loader-respin
Reviewer Review Type Date Requested Status
Michael Hudson-Doyle (community) Approve
Review via email: mp+104890@code.launchpad.net

Description of the change

This is a respin of https://code.launchpad.net/~zkrynicki/lava-core/plugin-loader/+merge/102357
(it is a respin as I want to make lava-core standalone, co-installable and independent of any other lava.* packages)

To quote my commit message:

  Add plugins and errors modules

  The PluginLoader class is a simple thin wrapper around pkg_resources entry
  points. It de-couples LAVA from grotty pkg_resources details and allows us to
  migrate away from that technology later. It also allows us to explore frozen
  applications that would allow lava to run on windows.

  The PluginLoader class has basically two methods: to load all plugins that
  "reside" in a particular namespace and to load an explicitly named plugin (also
  from a particular namespace). Namespace names are translated 1-to-1 to
  pkg_resources namespaces but are sufficiently abstract that this is not
  important. All plugins must explicitly inherit from a base class designated by
  the caller. In lava-core this is typically an interface that has all the
  expected methods and properties that the calling side will later expect from
  the returned object.

  The errors module starts with two related exception classes. In subsequent
  patches it will be used as a bag of assorted exceptions used in all of
  lava-core.

This MP depends on the previous 'history and logging' proposal.

To post a comment you must log in.
Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

This is exactly the same as the code I reviewed before right?

review: Approve
8. By Zygmunt Krynicki

Infrastructure for history and logging

10. By Zygmunt Krynicki

Make PluginLoader retain the order of entries from pkg_resources

Previously calling PluginLoader methods would trigger one-time initialization
via pkg_resources.iter_entry_points(). This method retains some of the ordering
of the declarations in setup.py files which can be useful, for example, for
listing commands in the expected order. This order was lost when entry points
were cached in a plain dictionary. This patch replaces all relevant uses of
plain dictionaries with OrderedDict (from the simplejson module)

11. By Zygmunt Krynicki

Add unit tests for lava.core.plugins

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'lava/core/errors.py'
--- lava/core/errors.py 1970-01-01 00:00:00 +0000
+++ lava/core/errors.py 2012-05-18 12:52:17 +0000
@@ -0,0 +1,38 @@
1# Copyright (C) 2011-2012 Linaro Limited
2#
3# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
4#
5# This file is part of lava-core
6#
7# lava-core is free software: you can redistribute it and/or modify
8# it under the terms of the GNU Lesser General Public License version 3
9# as published by the Free Software Foundation
10#
11# lava-core is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public License
17# along with lava-core. If not, see <http://www.gnu.org/licenses/>.
18
19from __future__ import absolute_import
20
21"""
22lava.core.errors
23================
24
25Common error classes with fuzzy meaning
26"""
27
28
29class PluginLookupError(LookupError):
30 """
31 Error raised when a plugin cannot be found
32 """
33
34
35class PluginLoadError(ImportError):
36 """
37 Error raised when a plugin cannot be loaded
38 """
039
=== added file 'lava/core/plugins.py'
--- lava/core/plugins.py 1970-01-01 00:00:00 +0000
+++ lava/core/plugins.py 2012-05-18 12:52:17 +0000
@@ -0,0 +1,131 @@
1# Copyright (C) 2011-2012 Linaro Limited
2#
3# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
4#
5# This file is part of lava-core
6#
7# lava-core is free software: you can redistribute it and/or modify
8# it under the terms of the GNU Lesser General Public License version 3
9# as published by the Free Software Foundation
10#
11# lava-core is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public License
17# along with lava-core. If not, see <http://www.gnu.org/licenses/>.
18
19from __future__ import absolute_import
20
21"""
22lava.core.plugins
23=================
24
25Formalized plugin system for LAVA.
26"""
27
28import pkg_resources
29from simplejson import OrderedDict
30
31from lava.core.errors import PluginLookupError, PluginLoadError
32from lava.core.logging import LoggingMixIn
33
34
35class PluginLoader(LoggingMixIn):
36 """
37 Loader for plugins that populate a specified namespace and conform to a
38 specified interface. It is expected that the objects pointed to by the
39 entry point system are _classes_. The plugin loader will call issubclass()
40 on them.
41
42 This plugin loader is based on pkg_resources machinery but it may be
43 expected to behave specially in the future. For example, when interacting
44 from a frozen binary on windows.
45
46 .. note::
47 Each instance has a cache of entry points that were discovered in the
48 past that is never purged. This should not be a problem under normal
49 circumstances.
50 """
51
52 def __init__(self, namespace, interface):
53 """
54 Create a plugin loader.
55 """
56 self.namespace = namespace
57 self.interface = interface
58 self._entry_points = None # Cache of entry_point.name -> entry_point
59
60 def load_all_plugins(self):
61 """
62 Load all plugins, returns an dict of objects.
63 Plugins that fail to load are skipped.
64 """
65 self._iter_entry_points_if_needed()
66 plugins = OrderedDict()
67 for entry_point in self._entry_points.itervalues():
68 try:
69 plugin = self._load(entry_point)
70 except PluginLoadError:
71 pass
72 else:
73 plugins[entry_point.name] = plugin
74 return plugins
75
76 def load_plugin(self, name):
77 """
78 Load a plugin by name.
79
80 @raises PluginLookupError if the plugin cannot be found.
81 @raises PluginLoadError if the plugin cannot be loaded
82 """
83 self._iter_entry_points_if_needed()
84 try:
85 entry_point = self._entry_points[name]
86 except KeyError:
87 raise PluginLookupError(name)
88 else:
89 return self._load(entry_point)
90
91 def _load(self, entry_point):
92 """
93 Load an validate an entry point object.
94
95 ImportErrors and DistributionNotFound errors are caught and trigger a
96 diagnostic message, the return value is None if that happens.
97
98 The value loaded from the entry point must be a class that is a
99 subclass of the interface class passed to PluginLoader constructor.
100 Failure to conform triggers another diagnostic message and a None
101 return value.
102 """
103 try:
104 self.logger.debug("Loading entry point: %r", entry_point)
105 plugin = entry_point.load()
106 except (ImportError, pkg_resources.DistributionNotFound):
107 self.logger.exception(
108 "Unable to load plugin: %s (%r)",
109 entry_point.name, entry_point)
110 raise PluginLoadError(entry_point)
111 else:
112 if (not isinstance(plugin, type)
113 or not issubclass(plugin, self.interface)):
114 self.logger.exception(
115 "Plugin %r does not implement interface %r",
116 entry_point.name, self.interface)
117 raise PluginLoadError(entry_point)
118 return plugin
119
120 def _iter_entry_points_if_needed(self):
121 """
122 Iterate all the entry points in a specified namespace.
123
124 This method keeps the result in self._entry_points because
125 pkg_resources.iter_entry_points() is surprisingly slow, doing a lot of
126 IO on tiny files all over the disk.
127 """
128 if self._entry_points is None:
129 self._entry_points = OrderedDict()
130 for entry_point in pkg_resources.iter_entry_points(self.namespace):
131 self._entry_points[entry_point.name] = entry_point
0132
=== added file 'lava/core/tests/test_plugins.py'
--- lava/core/tests/test_plugins.py 1970-01-01 00:00:00 +0000
+++ lava/core/tests/test_plugins.py 2012-05-18 12:52:17 +0000
@@ -0,0 +1,272 @@
1# Copyright (C) 2011-2012 Linaro Limited
2#
3# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
4#
5# This file is part of lava-core
6#
7# lava-core is free software: you can redistribute it and/or modify
8# it under the terms of the GNU Lesser General Public License version 3
9# as published by the Free Software Foundation
10#
11# lava-core is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU Lesser General Public License
17# along with lava-core. If not, see <http://www.gnu.org/licenses/>.
18
19"""
20lava.core.tests.test_plugins
21============================
22
23Unit tests for the lava.core.plugins module
24"""
25
26from mocker import Mocker, expect
27from unittest2 import TestCase
28import pkg_resources
29import simplejson
30
31from lava.core.plugins import PluginLoader
32from lava.core.errors import PluginLoadError, PluginLookupError
33
34
35class PluginLoaderTest(TestCase):
36
37 _namespace = "lava.unit_tests"
38 _interface = object
39 _name = "name"
40 _name2 = "name2"
41 _name3 = "name3"
42
43 def test__init__(self):
44 """
45 __init__() works
46 """
47 loader = PluginLoader(self._namespace, self._interface)
48 self.assertIs(loader._entry_points, None)
49 self.assertEqual(loader.namespace, self._namespace)
50 self.assertEqual(loader.interface, self._interface)
51
52 def test_load_all_plugins(self):
53 """
54 load_all_plugins() conceals PluginLoadError
55 """
56 mocker = Mocker()
57 entry_point = mocker.mock(pkg_resources.EntryPoint)
58 entry_point2 = mocker.mock(pkg_resources.EntryPoint)
59 entry_point3 = mocker.mock(pkg_resources.EntryPoint)
60 loader = PluginLoader(self._namespace, self._interface)
61 # Seed the cache
62 loader._entry_points = {
63 self._name: entry_point,
64 self._name2: entry_point2,
65 self._name3: entry_point3,
66 }
67 patched_loader = mocker.patch(loader)
68 expect(patched_loader._load(entry_point)).result(self._interface)
69 expect(entry_point.name).result(self._name)
70 # plugin 2 crashes
71 expect(patched_loader._load(entry_point2)).throw(PluginLoadError())
72 expect(patched_loader._load(entry_point3)).result(self._interface)
73 expect(entry_point3.name).result(self._name3)
74 with mocker:
75 plugins = loader.load_all_plugins()
76 # plugin 2 not returned, everything else, returned
77 self.assertEqual(plugins, {
78 self._name: self._interface,
79 self._name3: self._interface,
80 })
81
82 def test_load_all_plugins_keeps_order(self):
83 """
84 load_all_plugins() uses OrderedDict()
85 """
86 loader = PluginLoader(self._namespace, self._interface)
87 # Seed the cache
88 loader._entry_points = {}
89 plugins = loader.load_all_plugins()
90 self.assertIsInstance(plugins, simplejson.OrderedDict)
91
92 def test_load_plugin_missing(self):
93 """
94 load_plugin() raises PluginLookupError for missing plugins
95 """
96 loader = PluginLoader(self._namespace, self._interface)
97 # Seed the cache
98 loader._entry_points = {}
99 with self.assertRaises(PluginLookupError):
100 loader.load_plugin(self._name)
101
102 def test_load_plugin_failing_to_load(self):
103 """
104 load_plugin() propagates PluginLoadError
105 """
106 mocker = Mocker()
107 entry_point = mocker.mock(pkg_resources.EntryPoint)
108 loader = PluginLoader(self._namespace, self._interface)
109 loader._entry_points = {self._name: entry_point}
110 patched_loader = mocker.patch(loader)
111 expect(patched_loader._load(entry_point)).throw(PluginLoadError())
112 with mocker:
113 with self.assertRaises(PluginLoadError):
114 loader.load_plugin(self._name)
115
116 def test_load_plugin_all_good(self):
117 """
118 load_plugin() returns the plugin on success
119 """
120 mocker = Mocker()
121 entry_point = mocker.mock(pkg_resources.EntryPoint)
122 loader = PluginLoader(self._namespace, self._interface)
123 loader._entry_points = {self._name: entry_point}
124 patched_loader = mocker.patch(loader)
125 expect(patched_loader._load(entry_point)).result(self._interface)
126 with mocker:
127 plugin = loader.load_plugin(self._name)
128 self.assertEqual(plugin, self._interface)
129
130 def test__load_and_import_error(self):
131 """
132 _load() raises PluginLoadError when EntryPoint().load() raises
133 ImportError
134 """
135 mocker = Mocker()
136 loader = PluginLoader(self._namespace, self._interface)
137 patched_loader = mocker.patch(loader)
138 entry_point = mocker.mock(pkg_resources.EntryPoint)
139 patched_loader.logger.debug(
140 "Loading entry point: %r", entry_point)
141 expect(entry_point.load()).throw(ImportError())
142 expect(entry_point.name).result(self._name)
143 patched_loader.logger.exception(
144 "Unable to load plugin: %s (%r)",
145 self._name, entry_point)
146 with mocker:
147 with self.assertRaises(PluginLoadError):
148 loader._load(entry_point)
149
150 def test__load_and_the_other_error(self):
151 """
152 _load() raises PluginLoadError when EntryPoint.load() raises
153 pkg_resources.DistributionNotFound
154 """
155 mocker = Mocker()
156 loader = PluginLoader(self._namespace, self._interface)
157 patched_loader = mocker.patch(loader)
158 entry_point = mocker.mock(pkg_resources.EntryPoint)
159 patched_loader.logger.debug(
160 "Loading entry point: %r", entry_point)
161 expect(entry_point.load()).throw(pkg_resources.DistributionNotFound())
162 expect(entry_point.name).result(self._name)
163 patched_loader.logger.exception(
164 "Unable to load plugin: %s (%r)",
165 self._name, entry_point)
166 with mocker:
167 with self.assertRaises(PluginLoadError):
168 loader._load(entry_point)
169
170 def test__load_and_non_type_result(self):
171 """
172 _load() raises PluginLoadError when EntryPoint.load() returns a
173 non-type
174 """
175 mocker = Mocker()
176 loader = PluginLoader(self._namespace, self._interface)
177 patched_loader = mocker.patch(loader)
178 entry_point = mocker.mock(pkg_resources.EntryPoint)
179 patched_loader.logger.debug(
180 "Loading entry point: %r", entry_point)
181 expect(entry_point.load()).result(object()) # instance, not a type
182 expect(entry_point.name).result(self._name)
183 patched_loader.logger.exception(
184 "Plugin %r does not implement interface %r",
185 self._name, self._interface)
186 with mocker:
187 with self.assertRaises(PluginLoadError):
188 loader._load(entry_point)
189
190 def test__load_and_wrong_type_result(self):
191 """
192 _load() raises PluginLoadError when EntryPoint.load() returns a type
193 that does not match the interface
194 """
195 mocker = Mocker()
196 loader = PluginLoader(self._namespace, int) # expecting int-subclass
197 patched_loader = mocker.patch(loader)
198 entry_point = mocker.mock(pkg_resources.EntryPoint)
199 patched_loader.logger.debug(
200 "Loading entry point: %r", entry_point)
201 expect(entry_point.load()).result(str) # str, not int subclass
202 expect(entry_point.name).result(self._name)
203 patched_loader.logger.exception(
204 "Plugin %r does not implement interface %r",
205 self._name, int)
206 with mocker:
207 with self.assertRaises(PluginLoadError):
208 loader._load(entry_point)
209
210 def test__load_all_good(self):
211 """
212 _load() returns the plugin on success
213 """
214 mocker = Mocker()
215 loader = PluginLoader(self._namespace, self._interface)
216 patched_loader = mocker.patch(loader)
217 entry_point = mocker.mock(pkg_resources.EntryPoint)
218 patched_loader.logger.debug(
219 "Loading entry point: %r", entry_point)
220 expect(entry_point.load()).result(self._interface)
221 with mocker:
222 plugin = loader._load(entry_point)
223 # whatever we returned from load()
224 self.assertIs(plugin, self._interface)
225
226 def test__iter_entry_points_if_needed(self):
227 """
228 _iter_entry_points() fills the cache
229 """
230 mocker = Mocker()
231 entry = mocker.mock(pkg_resources.EntryPoint)
232 expect(entry.name).result(self._name)
233 entry2 = mocker.mock(pkg_resources.EntryPoint)
234 expect(entry2.name).result(self._name2)
235 # Need to use replace() as iter_entry_points is called indirectly
236 replaced_pkg_resources = mocker.replace(pkg_resources)
237 replaced_pkg_resources.iter_entry_points(self._namespace)
238 mocker.result(iter([entry, entry2]))
239 with mocker:
240 loader = PluginLoader(self._namespace, self._interface)
241 loader._iter_entry_points_if_needed()
242 # Mapping is correct
243 self.assertEqual(loader._entry_points,
244 {self._name: entry, self._name2: entry2})
245
246 def test__iter_entry_points_keeps_order(self):
247 """
248 _iter_entry_points_if_needed() uses OrderedDict
249 """
250 mocker = Mocker()
251 # Need to use replace() as iter_entry_points is called indirectly
252 replaced_pkg_resources = mocker.replace(pkg_resources)
253 replaced_pkg_resources.iter_entry_points(self._namespace)
254 mocker.result(iter([]))
255 with mocker:
256 loader = PluginLoader(self._namespace, self._interface)
257 loader._iter_entry_points_if_needed()
258 self.assertIsInstance(loader._entry_points, simplejson.OrderedDict)
259
260 def test__iter_entry_points_if_needed__twice(self):
261 """
262 _iter_entry_points_if_needed() only runs once
263 """
264 mocker = Mocker()
265 # Need to use replace() as iter_entry_points is called indirectly
266 replaced_pkg_resources = mocker.replace(pkg_resources)
267 replaced_pkg_resources.iter_entry_points(self._namespace)
268 mocker.result(iter([]))
269 with mocker:
270 loader = PluginLoader(self._namespace, self._interface)
271 loader._iter_entry_points_if_needed()
272 loader._iter_entry_points_if_needed()

Subscribers

People subscribed via source and target branches