1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 import os
18 import sys
19
20
21
22 import pkg_resources
23 from pkg_resources import DistributionNotFound
24
25 import shutil
26
27 from twisted.internet import task
28 from twisted.python import reflect
29 from twisted.python.failure import Failure
30
31 from elisa.core import common, __version__ as core_version
32 from elisa.core import default_config
33 from elisa.core.component import ComponentError
34 from elisa.core.components.message import Message
35 from elisa.core.log import Loggable
36 from elisa.core.utils import defer, locale_helper
37 from elisa.core.utils.internet import get_page
38
39 from elisa.extern import enum
40
41 try:
42 import simplejson as json
43 except ImportError:
44 try:
45 import json
46 except ImportError:
47 json = None
48
49 try:
50 from hashlib import md5
51 except ImportError:
52
53 from md5 import md5
54
55
56 default_plugin_dirs = []
57
58 _config_dir = default_config.CONFIG_DIR.encode(locale_helper.system_encoding())
59 default_plugin_dirs.append(os.path.join(_config_dir, 'plugins'))
60
61 _default_plugin_cache = os.path.join(default_config.CONFIG_DIR, 'plugins.cache')
65 """
66 Check if the distribution is a development egg.
67
68 Development eggs store egg-info and python code in the same toplevel
69 directory.
70
71 @param dist: plugin distribution
72 @type dist: C{Distribution}
73 """
74 return os.path.isdir(dist.location) and \
75 not os.path.isdir(os.path.join(dist.location, 'elisa'))
76
78 """
79 Get the top level directory of a plugin distribution.
80
81 Regular eggs and development eggs store files in different locations. Use
82 this function to access the top level directory of a plugin eg::
83
84 toplevel_directory = get_plugin_toplevel_directory(dist)
85 sub_directory = '%s/%s' % (toplevel_directory, 'sub')
86 requirement = pkg_resources.Requirement.parse(dist.project_name)
87 if pkg_resources.resource_isdir(requirement, sub_directory):
88 real_sub_path = pkg_resources.resource_filename(requirement,
89 sub_directory)
90
91 @param dist: plugin distribution
92 @type dist: C{Distribution}
93 """
94 assert dist.project_name.startswith('elisa-plugin-')
95
96 if is_development_egg(dist):
97
98 return ''
99
100
101
102 src_directory = dist.project_name[13:]
103
104
105
106 src_directory = src_directory.replace('-', '_')
107 return 'elisa/plugins/%s' % src_directory
108
113
115 return "Invalid component path %s" % self.component_name
116
120
122 return "Component %r not found" % self.component_name
123
126
129
132
135
137 """
138 A plugin has been enabled or disabled.
139
140 @ivar action: C{ActionType.ENABLED} or C{ActionType.DISABLED}
141 @type action: C{ActionType}
142 @ivar plugin_name: name of the plugin
143 @type plugin_name: C{str}
144 """
145 ActionType = enum.Enum('DISABLED', 'ENABLED')
146
147 - def __init__(self, plugin_name, action):
150
152 return '<PluginStatusMessage %s %s>' % (self.plugin_name, self.action)
153
155 """
156 The plugin registry is responsible for finding, loading, downloading,
157 enabling plugins and providing facilities to access their contents and
158 properties.
159
160 New plugins can be developed outside of Moovida's code to provide new
161 functionalities.
162 Base classes are defined to standardize common functionalities that are
163 likely to be reused.
164
165 DOCME more.
166 """
167
168 config_section_name = 'plugin_registry'
169 default_config = \
170 {'repository': 'http://plugins.moovida.com/plugin_list',
171 'update_plugin_cache': True,
172 'auto_update_plugins': True,
173 'auto_install_new_recommended_plugins': True}
174 config_doc = \
175 {'repository': 'The plugin repository to query for new plugins and ' \
176 'plugin updates.',
177 'update_plugin_cache': 'Whether to periodically query the plugin ' \
178 'repository to update the plugin cache. If False, automatic ' \
179 'plugin updates and downloading of new recommended plugins will ' \
180 'be deactivated.',
181 'auto_update_plugins': 'Whether to silently install all available ' \
182 'plugin updates. Ignored if update_plugin_cache is False.',
183 'auto_install_new_recommended_plugins': 'Whether to silently ' \
184 'install all new recommended plugins available. Ignored if ' \
185 'update_plugin_cache is False.'}
186
187 - def __init__(self, config=None, plugin_dirs=None):
202
213
214
216
217 dists = []
218
219 paths = []
220
221 for dist in list(working_set):
222 if not dist.key.startswith(name):
223 continue
224
225 dists.append(dist)
226
227 self.debug('Removing dist %s [%s]' % (dist, dist.location))
228
229
230
231
232 entries = [(path_entry, keys)
233 for path_entry, keys in working_set.entry_keys.iteritems()
234 if dist.key in keys]
235 for entry, keys in entries:
236 keys.remove(dist.key)
237
238 del working_set.by_key[dist.key]
239
240 if dist.location not in paths:
241
242
243 paths.append(dist.location)
244
245
246
247 dist_paths = [working_set.entries]
248
249 if working_set.entries is not sys.path:
250
251 dist_paths.append(sys.path)
252
253
254
255
256 if is_development_egg(dist):
257 self._fix_uninstalled_plugin(dist, 'remove')
258
259 for entries in dist_paths:
260
261
262
263
264 try:
265 entries.remove(dist.location)
266 except ValueError:
267
268
269
270 normalized_entries = [pkg_resources.normalize_path(entry)
271 for entry in entries]
272 try:
273 index = normalized_entries.index(dist.location)
274 del entries[index]
275 except ValueError:
276 pass
277
278 return dists, paths
279
307
309
310
311 import gst
312 registry = gst.registry_get_default()
313 registry.add_path(path)
314 registry.scan_path(path)
315
317 """
318 This is one more ugly hack to sys.path to compensate for the change of
319 order of sys.path resulting from load_plugins().
320 This is to work around https://bugs.launchpad.net/elisa/+bug/324444
321 """
322
323
324 for path in sys.path:
325 if 'python-support' in path:
326 sys.path.remove(path)
327 sys.path.append(path)
328
330 """
331 Load plugins from self.plugin_dirs.
332
333 @attention: This function should be called as early as possible at
334 startup, B{before} using any plugin.
335
336 @note: This function runs without returning to the reactor for as
337 long as it takes. There is no point in making it return before
338 it is done as the plugin environment needs to be set up before
339 any other part of elisa can run correctly.
340
341 @note: By default, all the available plugins are enabled.
342
343 @param disabled_plugins: a list of plugins that should be disabled
344 @type disabled_plugins: C{list} of C{str}
345 """
346 self.info('loading plugins from %s' % self.plugin_dirs)
347
348
349
350 old_dists, paths = self._deactivate_dist(pkg_resources.working_set, 'elisa')
351
352 import elisa.plugins
353
354
355 elisa.plugins.__path__ = []
356
357
358
359
360
361 plugin_dirs = self.plugin_dirs + paths
362
363 env = pkg_resources.Environment(plugin_dirs)
364 distributions, errors = pkg_resources.working_set.find_plugins(env)
365
366 for dist, error in errors.iteritems():
367 if isinstance(error, DistributionNotFound):
368 self.warning('plugin %s has the following '
369 'unmet dependencies: %s' % (dist.project_name, error))
370 else:
371 self.warning('plugin %s version conflict: %s' %
372 (dist.project_name, error))
373
374 def _plugin_priority(dist):
375
376
377
378
379
380
381
382 priority = 0
383 location = os.path.normcase(os.path.normpath(dist.location))
384 for plugin_dir in self.plugin_dirs:
385 norm_dir = os.path.normcase(os.path.normpath(plugin_dir))
386 if location.startswith(norm_dir):
387 priority |= 1
388 break
389
390 if dist.key != 'elisa' \
391 and not dist.key.startswith('elisa-plugin-') \
392 and dist.has_metadata('native_libs.txt'):
393 priority |= 2
394
395 if dist.key == 'gstreamer':
396
397
398 priority |= 4
399
400 elif dist.key == 'elisa':
401
402
403 priority |= 8
404
405 return priority
406
407 distributions.sort(key=_plugin_priority, reverse=True)
408
409 def _is_an_elisa_plugin(dist):
410
411
412
413
414
415
416
417
418
419
420
421 whitelist = ['gstreamer']
422 key = dist.key
423 if key == 'elisa':
424 return True
425 elif key.startswith('elisa-plugin-'):
426 return True
427 elif key in whitelist:
428 return True
429 else:
430 return False
431
432
433 for dist in distributions:
434 if _is_an_elisa_plugin(dist):
435 try:
436 self.load_plugin(dist)
437 except OSError:
438 continue
439
440 self._plugin_status[dist.key] = (dist.key not in disabled_plugins)
441
442
443 self._path_hacks_workaround()
444
445 self.info('loaded %d plugins' % len(self._plugin_status))
446
448 """
449 Load a given plugin in elisa.
450
451 @param plugin: the plugin to load
452 @type plugin: L{pkg_resources.Distribution}
453 """
454 self.info('Loading %s %s [%s]' % (plugin.key, plugin.version,
455 plugin.location))
456
457
458
459 self.get_plugin_metadata(plugin)
460 if hasattr(plugin, 'platforms') and len(plugin.platforms) > 0:
461 current_platform = pkg_resources.get_platform()
462 compatible = False
463 for platform in plugin.platforms:
464 if current_platform.startswith(platform):
465 compatible = True
466 break
467 if not compatible:
468 raise OSError('%s cannot be used on this platform.' % \
469 plugin.key)
470
471
472
473 if is_development_egg(plugin):
474 self._fix_uninstalled_plugin(plugin, 'add')
475
476
477 pkg_resources.working_set.add(plugin)
478
479 if plugin.key == 'elisa':
480
481
482 return
483
484
485
486
487 plugins_path = os.path.join(plugin.location, 'elisa', 'plugins')
488 if os.path.exists(plugins_path) and os.path.isdir(plugins_path):
489 import elisa.plugins
490 if plugins_path not in elisa.plugins.__path__:
491 elisa.plugins.__path__.append(plugins_path)
492
493
494
495 if plugin.project_name.startswith('elisa-plugin'):
496
497 toplevel_directory = get_plugin_toplevel_directory(plugin)
498
499
500 gstreamer_directory = '%s/%s' % (toplevel_directory, 'gstreamer')
501 requirement = pkg_resources.Requirement.parse(plugin.project_name)
502 if pkg_resources.resource_isdir(requirement, gstreamer_directory):
503 gst_path = pkg_resources.resource_filename(requirement,
504 gstreamer_directory)
505
506
507
508
509
510 gst_path = gst_path.decode(locale_helper.system_encoding())
511 self._add_gstreamer_path(gst_path)
512 else:
513 plugin.activate()
514
515 self._plugin_status[plugin.key] = False
516
517
518 try:
519 load_hook = plugin.load_entry_point('elisa.core.plugin_registry',
520 'load')
521 except ImportError:
522 pass
523 else:
524 load_hook()
525
526
528 """
529 Unload a given plugin from elisa.
530
531 @param plugin: the plugin to unload
532 @type plugin: L{pkg_resources.Distribution}
533
534 @return: a deferred fired when the plugin is fully unloaded
535 @rtype: L{twisted.internet.defer.Deferred}
536 """
537
538 def failed_to_disable(failure):
539 failure.trap(PluginAlreadyDisabled)
540
541 dfr = self.disable_plugin(plugin.key)
542 dfr.addErrback(failed_to_disable)
543
544 def unload(result):
545 self.info('Unloading plugin %s' % plugin.key)
546
547 return result
548
549 dfr.addCallback(unload)
550 return dfr
551
553 """
554 Register a callback to be fired upon (de)activation of a plugin.
555 This callback will be passed the plugin (L{pkg_resources.Distribution})
556 and the new status (C{bool}, C{True} for enabled, C{False} for
557 disabled) as parameters, and should return a deferred. The plugin
558 status will be considered as changed (and the corresponding message
559 emitted) only when all the resulting deferreds of the registered
560 callbacks have fired (or errored).
561
562 @param callback: a callback
563 @type callback: C{callable}
564 """
565 self._plugin_status_changed_callbacks.append(callback)
566
568 """
569 Unregister a callback that was previously registered to be fired upon
570 (de)activation of a plugin.
571
572 @param callback: a callback previously registered
573 @type callback: C{callable}
574
575 @raise ValueError: if the callback is not registered
576 """
577 self._plugin_status_changed_callbacks.remove(callback)
578
580
581 def print_warning(failure, callback):
582 self.warning("Callback %s(%s, %s) failed: %s" %
583 (callback, plugin, status, failure))
584
585 def iterate_registered_callbacks():
586 for callback in self._plugin_status_changed_callbacks:
587 dfr = callback(plugin, status)
588 dfr.addErrback(print_warning, callback)
589 yield dfr
590
591 return task.coiterate(iterate_registered_callbacks())
592
594 """
595 Enable all the plugins that should be enabled.
596
597 @attention: this method can be called only once and should be called
598 upon startup of the application, after L{load_plugins}.
599
600 @return: a deferred fired when all plugins have been enabled
601 @rtype: L{twisted.internet.defer.Deferred}
602 """
603 def iterate_plugins():
604 for plugin_name, enabled in self._plugin_status.iteritems():
605 if not enabled:
606 continue
607
608 self._plugin_status[plugin_name] = False
609 yield self.enable_plugin(plugin_name)
610
611 return task.coiterate(iterate_plugins())
612
614 """
615 Enable a plugin.
616
617 @param plugin_name: the name of the plugin to enable
618 @type plugin_name: C{str}
619
620 @return: a deferred fired when the plugin is enabled
621 @rtype: L{twisted.internet.defer.Deferred}
622 """
623 plugin = self.get_plugin_by_name(plugin_name)
624
625 def update_configuration(result):
626 config = common.application.config
627 disabled_plugins = config.get_option('disabled_plugins',
628 section='general',
629 default=[])
630 try:
631 disabled_plugins.remove(plugin_name)
632 except ValueError:
633 pass
634 else:
635 config.set_option('disabled_plugins', disabled_plugins,
636 section='general')
637 return result
638
639 def send_message(result):
640 message = PluginStatusMessage(plugin_name,
641 PluginStatusMessage.ActionType.ENABLED)
642 common.application.bus.send_message(message)
643
644 try:
645 if self._plugin_status[plugin_name]:
646 return defer.fail(PluginAlreadyEnabled(plugin_name))
647 except KeyError:
648 return defer.fail(PluginNotFound(plugin_name))
649
650 self.info('Enabling plugin %s' % plugin_name)
651 self._plugin_status[plugin_name] = True
652
653 dfr = self._call_hook(plugin_name, 'enable')
654 dfr.addCallback(update_configuration)
655 dfr.addCallback(self._fire_registered_callbacks, plugin, True)
656 dfr.addCallback(send_message)
657 return dfr
658
660 """
661 Disable a plugin.
662
663 @param plugin_name: the name of the plugin to disable
664 @type plugin_name: C{str}
665 @param permanent: whether the plugin should be disabled permanently
666 (in the configuration), or only for this run
667 @type permanent: C{bool}
668
669 @return: a deferred fired when the plugin is disabled
670 @rtype: L{twisted.internet.defer.Deferred}
671 """
672 plugin = self.get_plugin_by_name(plugin_name)
673
674 def update_configuration(result):
675 config = common.application.config
676 disabled_plugins = config.get_option('disabled_plugins',
677 section='general',
678 default=[])
679 if plugin_name not in disabled_plugins:
680 disabled_plugins.append(plugin_name)
681 config.set_option('disabled_plugins', disabled_plugins,
682 section='general')
683 return result
684
685 def send_message(result):
686 message = PluginStatusMessage(plugin_name,
687 PluginStatusMessage.ActionType.DISABLED)
688 common.application.bus.send_message(message)
689
690 try:
691 if not self._plugin_status[plugin_name]:
692 return defer.fail(PluginAlreadyDisabled(plugin_name))
693 except KeyError:
694 return defer.fail(PluginNotFound(plugin_name))
695
696 self.info('Disabling plugin %s' % plugin_name)
697 self._plugin_status[plugin_name] = False
698
699 dfr = self._call_hook(plugin_name, 'disable')
700 if permanent:
701 dfr.addCallback(update_configuration)
702 dfr.addCallback(self._fire_registered_callbacks, plugin, False)
703 dfr.addCallback(send_message)
704 return dfr
705
707 """
708 Get the list of available plugins.
709
710 This call returns (plugin_name, status) tuples, where status is C{True}
711 if the plugin is enabled, C{False} otherwise.
712
713 @return: a generator yielding (plugin_name, status) tuples
714 @rtype: C{generator}
715 """
716 return self._plugin_status.iteritems()
717
719 """
720 Get the list of enabled plugins.
721
722 @return: generator yielding plugin names
723 @rtype: C{generator}
724 """
725 for name, status in self._plugin_status.iteritems():
726 if status == False:
727 continue
728
729 yield name
730
732 """
733 Get the names of the installed plugins.
734
735 @return: a generator yielding plugin names
736 @rtype: C{generator}
737 """
738 return self._plugin_status.iterkeys()
739
741 """
742 Return the plugin matching a given name.
743
744 @param plugin_name: the name of the plugin
745 @type plugin_name: C{str}
746
747 @return: the plugin, or C{None} if no plugin matches the given name
748 @rtype: L{pkg_resources.Distribution}
749 """
750 requirement = pkg_resources.Requirement.parse(plugin_name)
751 return pkg_resources.working_set.find(requirement)
752
792
793 - def _get(self, uri):
796
798 """
799 Here the actual deserialization is done.
800
801 The present file format is json, but that can be changed without
802 affecting the rest of the system.
803
804 @param cache: the serialized data
805 @type cache: C{str}
806 @return: the C{list} of plugins
807 @rtype: C{list} of C{dict}
808 """
809 if json is None:
810 raise DeserializationError("Couldn't import json.")
811
812 try:
813 return json.JSONDecoder().decode(cache)
814 except ValueError:
815 raise DeserializationError("Error while deserializing cache.")
816
817 @property
820
821 @property
824
826 """
827 Load the cached information about downloadable plugins.
828
829 @return: whether the loading went well.
830 @rtype: C{bool}
831 """
832 try:
833 cache = open(self.plugin_cache).read()
834 except IOError:
835 self.warning("Cannot read cache file '%s'" % self.plugin_cache)
836 return False
837
838 try:
839 self._downloadable_plugins = self._deserialize_cache(cache)
840 return True
841 except DeserializationError:
842 self._downloadable_plugins = []
843 return False
844
846 """
847 Update the cached information about downloadable plugins.
848
849 At present only one remote, hardcoded repository is supported.
850
851 @return: a deferred triggered when the cache is updated
852 @rtype: L{elisa.core.utils.defer.Deferred}
853 """
854 def deserialize_cache(result):
855 try:
856 self._downloadable_plugins = self._deserialize_cache(result)
857 except DeserializationError, error:
858 return defer.fail(error)
859 else:
860 return result
861
862 def store_cache(result):
863 cache = open(self.plugin_cache, 'wb')
864 cache.write(result)
865 cache.close()
866
867 self.log("Stored the plugin information to cache file %s" % \
868 self.plugin_cache)
869
870 return self.plugin_cache
871
872 def error(failure):
873 self.warning("Couldn't update the cache: %s" % failure)
874 return failure
875
876 uri = '%s?elisa_version=%s' % (self.plugin_repository, core_version)
877 dfr = self._get(uri)
878 dfr.addCallbacks(deserialize_cache, error)
879 dfr.addCallbacks(store_cache, error)
880 return dfr
881
883 """
884 The list of downloadable plugins.
885
886 Each plugin is represented as a Python dictionary.
887
888 @param reload_cache: whether to reload the local cache from disk
889 @type reload_cache: C{bool}
890 @return: a C{list} of one C{dict} per plugin.
891 @rtype: C{list}
892 """
893 if reload_cache:
894 self.reload_cache()
895 return self._downloadable_plugins
896
898 """
899 Download one plugin.
900
901 A plugin is represented with a dictionary. Some expected keys are
902 listed at http://www.moovida.com/wiki/Specs/PluginsMetadata. Here we
903 just rely on 'egg_name' and 'uri'. If they change, this code will
904 simply break.
905
906 @param plugin: the plugin dictionary
907 @type plugin: C{dict}
908
909 @return: a deferred triggered when done, reporting the path to
910 the downloaded egg file
911 @rtype: L{elisa.core.utils.defer.Deferred}
912 """
913 def check_integrity(egg_data):
914 try:
915 ref_checksum = plugin['checksum']
916 except KeyError:
917
918 ref_checksum = None
919 checksum = md5(egg_data).hexdigest()
920 if checksum != ref_checksum:
921 msg = 'Egg integrity verification failed for %s.' % \
922 str(plugin['egg_name'])
923 self.warning(msg)
924 return Failure(ValueError(msg))
925 return egg_data
926
927 def store_plugin(egg_data):
928 plugin_dir = default_plugin_dirs[0]
929 if not os.path.exists(plugin_dir):
930 os.mkdir(plugin_dir)
931 plugin_path = os.path.join(plugin_dir, str(plugin['egg_name']))
932 plugin_file = open(plugin_path, 'wb')
933 plugin_file.write(egg_data)
934 plugin_file.close()
935
936 self.log("Plugin downloaded and stored into %s" % plugin_path)
937
938 return plugin_path
939
940 def error(failure):
941 self.warning("Couldn't download plugin: %s" % failure)
942 return failure
943
944 dfr = self._get(str(plugin['uri']))
945 dfr.addCallbacks(check_integrity, error)
946 dfr.addCallbacks(store_plugin, error)
947 return dfr
948
950 """
951 Install a plugin from a local egg file.
952
953 If needed, the egg file will be copied over to the local plugins
954 directory and the older version of the plugin will be unloaded.
955 The plugin will then be loaded and enabled.
956
957 @param egg_file: the full path to the egg file on disk
958 @type egg_file: C{str}
959 @param plugin_name: the internal name of the plugin
960 @type plugin_name: C{str}
961
962 @return: a deferred fired when the plugin is installed
963 @rtype: L{elisa.core.utils.defer.Deferred}
964 """
965
966 plugins_dir = default_plugin_dirs[0]
967 dirname = os.path.dirname(egg_file)
968 basename = os.path.basename(egg_file)
969 if dirname != plugins_dir:
970 try:
971 shutil.copyfile(egg_file, os.path.join(plugins_dir, basename))
972 except (shutil.Error, IOError), error:
973 msg = 'Failed to install %s: %s.' % (basename, error)
974 self.warning(msg)
975 return defer