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
1=== added file 'lava/core/errors.py'
2--- lava/core/errors.py 1970-01-01 00:00:00 +0000
3+++ lava/core/errors.py 2012-05-18 12:52:17 +0000
4@@ -0,0 +1,38 @@
5+# Copyright (C) 2011-2012 Linaro Limited
6+#
7+# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
8+#
9+# This file is part of lava-core
10+#
11+# lava-core is free software: you can redistribute it and/or modify
12+# it under the terms of the GNU Lesser General Public License version 3
13+# as published by the Free Software Foundation
14+#
15+# lava-core is distributed in the hope that it will be useful,
16+# but WITHOUT ANY WARRANTY; without even the implied warranty of
17+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+# GNU General Public License for more details.
19+#
20+# You should have received a copy of the GNU Lesser General Public License
21+# along with lava-core. If not, see <http://www.gnu.org/licenses/>.
22+
23+from __future__ import absolute_import
24+
25+"""
26+lava.core.errors
27+================
28+
29+Common error classes with fuzzy meaning
30+"""
31+
32+
33+class PluginLookupError(LookupError):
34+ """
35+ Error raised when a plugin cannot be found
36+ """
37+
38+
39+class PluginLoadError(ImportError):
40+ """
41+ Error raised when a plugin cannot be loaded
42+ """
43
44=== added file 'lava/core/plugins.py'
45--- lava/core/plugins.py 1970-01-01 00:00:00 +0000
46+++ lava/core/plugins.py 2012-05-18 12:52:17 +0000
47@@ -0,0 +1,131 @@
48+# Copyright (C) 2011-2012 Linaro Limited
49+#
50+# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
51+#
52+# This file is part of lava-core
53+#
54+# lava-core is free software: you can redistribute it and/or modify
55+# it under the terms of the GNU Lesser General Public License version 3
56+# as published by the Free Software Foundation
57+#
58+# lava-core is distributed in the hope that it will be useful,
59+# but WITHOUT ANY WARRANTY; without even the implied warranty of
60+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
61+# GNU General Public License for more details.
62+#
63+# You should have received a copy of the GNU Lesser General Public License
64+# along with lava-core. If not, see <http://www.gnu.org/licenses/>.
65+
66+from __future__ import absolute_import
67+
68+"""
69+lava.core.plugins
70+=================
71+
72+Formalized plugin system for LAVA.
73+"""
74+
75+import pkg_resources
76+from simplejson import OrderedDict
77+
78+from lava.core.errors import PluginLookupError, PluginLoadError
79+from lava.core.logging import LoggingMixIn
80+
81+
82+class PluginLoader(LoggingMixIn):
83+ """
84+ Loader for plugins that populate a specified namespace and conform to a
85+ specified interface. It is expected that the objects pointed to by the
86+ entry point system are _classes_. The plugin loader will call issubclass()
87+ on them.
88+
89+ This plugin loader is based on pkg_resources machinery but it may be
90+ expected to behave specially in the future. For example, when interacting
91+ from a frozen binary on windows.
92+
93+ .. note::
94+ Each instance has a cache of entry points that were discovered in the
95+ past that is never purged. This should not be a problem under normal
96+ circumstances.
97+ """
98+
99+ def __init__(self, namespace, interface):
100+ """
101+ Create a plugin loader.
102+ """
103+ self.namespace = namespace
104+ self.interface = interface
105+ self._entry_points = None # Cache of entry_point.name -> entry_point
106+
107+ def load_all_plugins(self):
108+ """
109+ Load all plugins, returns an dict of objects.
110+ Plugins that fail to load are skipped.
111+ """
112+ self._iter_entry_points_if_needed()
113+ plugins = OrderedDict()
114+ for entry_point in self._entry_points.itervalues():
115+ try:
116+ plugin = self._load(entry_point)
117+ except PluginLoadError:
118+ pass
119+ else:
120+ plugins[entry_point.name] = plugin
121+ return plugins
122+
123+ def load_plugin(self, name):
124+ """
125+ Load a plugin by name.
126+
127+ @raises PluginLookupError if the plugin cannot be found.
128+ @raises PluginLoadError if the plugin cannot be loaded
129+ """
130+ self._iter_entry_points_if_needed()
131+ try:
132+ entry_point = self._entry_points[name]
133+ except KeyError:
134+ raise PluginLookupError(name)
135+ else:
136+ return self._load(entry_point)
137+
138+ def _load(self, entry_point):
139+ """
140+ Load an validate an entry point object.
141+
142+ ImportErrors and DistributionNotFound errors are caught and trigger a
143+ diagnostic message, the return value is None if that happens.
144+
145+ The value loaded from the entry point must be a class that is a
146+ subclass of the interface class passed to PluginLoader constructor.
147+ Failure to conform triggers another diagnostic message and a None
148+ return value.
149+ """
150+ try:
151+ self.logger.debug("Loading entry point: %r", entry_point)
152+ plugin = entry_point.load()
153+ except (ImportError, pkg_resources.DistributionNotFound):
154+ self.logger.exception(
155+ "Unable to load plugin: %s (%r)",
156+ entry_point.name, entry_point)
157+ raise PluginLoadError(entry_point)
158+ else:
159+ if (not isinstance(plugin, type)
160+ or not issubclass(plugin, self.interface)):
161+ self.logger.exception(
162+ "Plugin %r does not implement interface %r",
163+ entry_point.name, self.interface)
164+ raise PluginLoadError(entry_point)
165+ return plugin
166+
167+ def _iter_entry_points_if_needed(self):
168+ """
169+ Iterate all the entry points in a specified namespace.
170+
171+ This method keeps the result in self._entry_points because
172+ pkg_resources.iter_entry_points() is surprisingly slow, doing a lot of
173+ IO on tiny files all over the disk.
174+ """
175+ if self._entry_points is None:
176+ self._entry_points = OrderedDict()
177+ for entry_point in pkg_resources.iter_entry_points(self.namespace):
178+ self._entry_points[entry_point.name] = entry_point
179
180=== added file 'lava/core/tests/test_plugins.py'
181--- lava/core/tests/test_plugins.py 1970-01-01 00:00:00 +0000
182+++ lava/core/tests/test_plugins.py 2012-05-18 12:52:17 +0000
183@@ -0,0 +1,272 @@
184+# Copyright (C) 2011-2012 Linaro Limited
185+#
186+# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
187+#
188+# This file is part of lava-core
189+#
190+# lava-core is free software: you can redistribute it and/or modify
191+# it under the terms of the GNU Lesser General Public License version 3
192+# as published by the Free Software Foundation
193+#
194+# lava-core is distributed in the hope that it will be useful,
195+# but WITHOUT ANY WARRANTY; without even the implied warranty of
196+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
197+# GNU General Public License for more details.
198+#
199+# You should have received a copy of the GNU Lesser General Public License
200+# along with lava-core. If not, see <http://www.gnu.org/licenses/>.
201+
202+"""
203+lava.core.tests.test_plugins
204+============================
205+
206+Unit tests for the lava.core.plugins module
207+"""
208+
209+from mocker import Mocker, expect
210+from unittest2 import TestCase
211+import pkg_resources
212+import simplejson
213+
214+from lava.core.plugins import PluginLoader
215+from lava.core.errors import PluginLoadError, PluginLookupError
216+
217+
218+class PluginLoaderTest(TestCase):
219+
220+ _namespace = "lava.unit_tests"
221+ _interface = object
222+ _name = "name"
223+ _name2 = "name2"
224+ _name3 = "name3"
225+
226+ def test__init__(self):
227+ """
228+ __init__() works
229+ """
230+ loader = PluginLoader(self._namespace, self._interface)
231+ self.assertIs(loader._entry_points, None)
232+ self.assertEqual(loader.namespace, self._namespace)
233+ self.assertEqual(loader.interface, self._interface)
234+
235+ def test_load_all_plugins(self):
236+ """
237+ load_all_plugins() conceals PluginLoadError
238+ """
239+ mocker = Mocker()
240+ entry_point = mocker.mock(pkg_resources.EntryPoint)
241+ entry_point2 = mocker.mock(pkg_resources.EntryPoint)
242+ entry_point3 = mocker.mock(pkg_resources.EntryPoint)
243+ loader = PluginLoader(self._namespace, self._interface)
244+ # Seed the cache
245+ loader._entry_points = {
246+ self._name: entry_point,
247+ self._name2: entry_point2,
248+ self._name3: entry_point3,
249+ }
250+ patched_loader = mocker.patch(loader)
251+ expect(patched_loader._load(entry_point)).result(self._interface)
252+ expect(entry_point.name).result(self._name)
253+ # plugin 2 crashes
254+ expect(patched_loader._load(entry_point2)).throw(PluginLoadError())
255+ expect(patched_loader._load(entry_point3)).result(self._interface)
256+ expect(entry_point3.name).result(self._name3)
257+ with mocker:
258+ plugins = loader.load_all_plugins()
259+ # plugin 2 not returned, everything else, returned
260+ self.assertEqual(plugins, {
261+ self._name: self._interface,
262+ self._name3: self._interface,
263+ })
264+
265+ def test_load_all_plugins_keeps_order(self):
266+ """
267+ load_all_plugins() uses OrderedDict()
268+ """
269+ loader = PluginLoader(self._namespace, self._interface)
270+ # Seed the cache
271+ loader._entry_points = {}
272+ plugins = loader.load_all_plugins()
273+ self.assertIsInstance(plugins, simplejson.OrderedDict)
274+
275+ def test_load_plugin_missing(self):
276+ """
277+ load_plugin() raises PluginLookupError for missing plugins
278+ """
279+ loader = PluginLoader(self._namespace, self._interface)
280+ # Seed the cache
281+ loader._entry_points = {}
282+ with self.assertRaises(PluginLookupError):
283+ loader.load_plugin(self._name)
284+
285+ def test_load_plugin_failing_to_load(self):
286+ """
287+ load_plugin() propagates PluginLoadError
288+ """
289+ mocker = Mocker()
290+ entry_point = mocker.mock(pkg_resources.EntryPoint)
291+ loader = PluginLoader(self._namespace, self._interface)
292+ loader._entry_points = {self._name: entry_point}
293+ patched_loader = mocker.patch(loader)
294+ expect(patched_loader._load(entry_point)).throw(PluginLoadError())
295+ with mocker:
296+ with self.assertRaises(PluginLoadError):
297+ loader.load_plugin(self._name)
298+
299+ def test_load_plugin_all_good(self):
300+ """
301+ load_plugin() returns the plugin on success
302+ """
303+ mocker = Mocker()
304+ entry_point = mocker.mock(pkg_resources.EntryPoint)
305+ loader = PluginLoader(self._namespace, self._interface)
306+ loader._entry_points = {self._name: entry_point}
307+ patched_loader = mocker.patch(loader)
308+ expect(patched_loader._load(entry_point)).result(self._interface)
309+ with mocker:
310+ plugin = loader.load_plugin(self._name)
311+ self.assertEqual(plugin, self._interface)
312+
313+ def test__load_and_import_error(self):
314+ """
315+ _load() raises PluginLoadError when EntryPoint().load() raises
316+ ImportError
317+ """
318+ mocker = Mocker()
319+ loader = PluginLoader(self._namespace, self._interface)
320+ patched_loader = mocker.patch(loader)
321+ entry_point = mocker.mock(pkg_resources.EntryPoint)
322+ patched_loader.logger.debug(
323+ "Loading entry point: %r", entry_point)
324+ expect(entry_point.load()).throw(ImportError())
325+ expect(entry_point.name).result(self._name)
326+ patched_loader.logger.exception(
327+ "Unable to load plugin: %s (%r)",
328+ self._name, entry_point)
329+ with mocker:
330+ with self.assertRaises(PluginLoadError):
331+ loader._load(entry_point)
332+
333+ def test__load_and_the_other_error(self):
334+ """
335+ _load() raises PluginLoadError when EntryPoint.load() raises
336+ pkg_resources.DistributionNotFound
337+ """
338+ mocker = Mocker()
339+ loader = PluginLoader(self._namespace, self._interface)
340+ patched_loader = mocker.patch(loader)
341+ entry_point = mocker.mock(pkg_resources.EntryPoint)
342+ patched_loader.logger.debug(
343+ "Loading entry point: %r", entry_point)
344+ expect(entry_point.load()).throw(pkg_resources.DistributionNotFound())
345+ expect(entry_point.name).result(self._name)
346+ patched_loader.logger.exception(
347+ "Unable to load plugin: %s (%r)",
348+ self._name, entry_point)
349+ with mocker:
350+ with self.assertRaises(PluginLoadError):
351+ loader._load(entry_point)
352+
353+ def test__load_and_non_type_result(self):
354+ """
355+ _load() raises PluginLoadError when EntryPoint.load() returns a
356+ non-type
357+ """
358+ mocker = Mocker()
359+ loader = PluginLoader(self._namespace, self._interface)
360+ patched_loader = mocker.patch(loader)
361+ entry_point = mocker.mock(pkg_resources.EntryPoint)
362+ patched_loader.logger.debug(
363+ "Loading entry point: %r", entry_point)
364+ expect(entry_point.load()).result(object()) # instance, not a type
365+ expect(entry_point.name).result(self._name)
366+ patched_loader.logger.exception(
367+ "Plugin %r does not implement interface %r",
368+ self._name, self._interface)
369+ with mocker:
370+ with self.assertRaises(PluginLoadError):
371+ loader._load(entry_point)
372+
373+ def test__load_and_wrong_type_result(self):
374+ """
375+ _load() raises PluginLoadError when EntryPoint.load() returns a type
376+ that does not match the interface
377+ """
378+ mocker = Mocker()
379+ loader = PluginLoader(self._namespace, int) # expecting int-subclass
380+ patched_loader = mocker.patch(loader)
381+ entry_point = mocker.mock(pkg_resources.EntryPoint)
382+ patched_loader.logger.debug(
383+ "Loading entry point: %r", entry_point)
384+ expect(entry_point.load()).result(str) # str, not int subclass
385+ expect(entry_point.name).result(self._name)
386+ patched_loader.logger.exception(
387+ "Plugin %r does not implement interface %r",
388+ self._name, int)
389+ with mocker:
390+ with self.assertRaises(PluginLoadError):
391+ loader._load(entry_point)
392+
393+ def test__load_all_good(self):
394+ """
395+ _load() returns the plugin on success
396+ """
397+ mocker = Mocker()
398+ loader = PluginLoader(self._namespace, self._interface)
399+ patched_loader = mocker.patch(loader)
400+ entry_point = mocker.mock(pkg_resources.EntryPoint)
401+ patched_loader.logger.debug(
402+ "Loading entry point: %r", entry_point)
403+ expect(entry_point.load()).result(self._interface)
404+ with mocker:
405+ plugin = loader._load(entry_point)
406+ # whatever we returned from load()
407+ self.assertIs(plugin, self._interface)
408+
409+ def test__iter_entry_points_if_needed(self):
410+ """
411+ _iter_entry_points() fills the cache
412+ """
413+ mocker = Mocker()
414+ entry = mocker.mock(pkg_resources.EntryPoint)
415+ expect(entry.name).result(self._name)
416+ entry2 = mocker.mock(pkg_resources.EntryPoint)
417+ expect(entry2.name).result(self._name2)
418+ # Need to use replace() as iter_entry_points is called indirectly
419+ replaced_pkg_resources = mocker.replace(pkg_resources)
420+ replaced_pkg_resources.iter_entry_points(self._namespace)
421+ mocker.result(iter([entry, entry2]))
422+ with mocker:
423+ loader = PluginLoader(self._namespace, self._interface)
424+ loader._iter_entry_points_if_needed()
425+ # Mapping is correct
426+ self.assertEqual(loader._entry_points,
427+ {self._name: entry, self._name2: entry2})
428+
429+ def test__iter_entry_points_keeps_order(self):
430+ """
431+ _iter_entry_points_if_needed() uses OrderedDict
432+ """
433+ mocker = Mocker()
434+ # Need to use replace() as iter_entry_points is called indirectly
435+ replaced_pkg_resources = mocker.replace(pkg_resources)
436+ replaced_pkg_resources.iter_entry_points(self._namespace)
437+ mocker.result(iter([]))
438+ with mocker:
439+ loader = PluginLoader(self._namespace, self._interface)
440+ loader._iter_entry_points_if_needed()
441+ self.assertIsInstance(loader._entry_points, simplejson.OrderedDict)
442+
443+ def test__iter_entry_points_if_needed__twice(self):
444+ """
445+ _iter_entry_points_if_needed() only runs once
446+ """
447+ mocker = Mocker()
448+ # Need to use replace() as iter_entry_points is called indirectly
449+ replaced_pkg_resources = mocker.replace(pkg_resources)
450+ replaced_pkg_resources.iter_entry_points(self._namespace)
451+ mocker.result(iter([]))
452+ with mocker:
453+ loader = PluginLoader(self._namespace, self._interface)
454+ loader._iter_entry_points_if_needed()
455+ loader._iter_entry_points_if_needed()

Subscribers

People subscribed via source and target branches