Package elisa :: Package core :: Module plugin_registry

Source Code for Module elisa.core.plugin_registry

   1  # -*- coding: utf-8 -*- 
   2  # Moovida - Home multimedia server 
   3  # Copyright (C) 2006-2009 Fluendo Embedded S.L. (www.fluendo.com). 
   4  # All rights reserved. 
   5  # 
   6  # This file is available under one of two license agreements. 
   7  # 
   8  # This file is licensed under the GPL version 3. 
   9  # See "LICENSE.GPL" in the root of this distribution including a special 
  10  # exception to use Moovida with Fluendo's plugins. 
  11  # 
  12  # The GPL part of Moovida is also available under a commercial licensing 
  13  # agreement from Fluendo. 
  14  # See "LICENSE.Moovida" in the root directory of this distribution package 
  15  # for details on that license. 
  16   
  17  import os 
  18  import sys 
  19  # gst is imported later, because we might not have it yet 
  20  #import gst 
  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      # hashlib is new in Python 2.5 
  53      from md5 import md5 
  54   
  55   
  56  default_plugin_dirs = [] 
  57  # pkg_resources expects the dir to be in the system encoding 
  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') 
62 63 64 -def is_development_egg(dist):
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
77 -def get_plugin_toplevel_directory(dist):
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 # development eggs contain egg-info and code in the same dir 98 return '' 99 100 # installed eggs have everything under elisa/plugins/plugin_name (stripping 101 # elisa-plugin- from project_name) 102 src_directory = dist.project_name[13:] 103 # some plugins have a dash in their name (like the apple-trailers one) 104 # and code is hosted in same directory name but the dashes are 105 # replaced by underscores 106 src_directory = src_directory.replace('-', '_') 107 return 'elisa/plugins/%s' % src_directory
108
109 110 -class InvalidComponentPath(ComponentError):
111 - def __init__(self, component_name):
112 super(InvalidComponentPath, self).__init__(component_name)
113
114 - def __str__(self):
115 return "Invalid component path %s" % self.component_name
116
117 -class ComponentNotFound(ComponentError):
118 - def __init__(self, component_name):
119 super(ComponentNotFound, self).__init__(component_name)
120
121 - def __str__(self):
122 return "Component %r not found" % self.component_name
123
124 -class PluginNotFound(Exception):
125 pass
126
127 -class PluginAlreadyEnabled(Exception):
128 pass
129
130 -class PluginAlreadyDisabled(Exception):
131 pass
132
133 -class DeserializationError(Exception):
134 pass
135
136 -class PluginStatusMessage(Message):
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):
148 self.plugin_name = plugin_name 149 self.action = action
150
151 - def __str__(self):
152 return '<PluginStatusMessage %s %s>' % (self.plugin_name, self.action)
153
154 -class PluginRegistry(Loggable):
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):
188 super(PluginRegistry, self).__init__() 189 190 if plugin_dirs is None: 191 from elisa.core.launcher import plugin_directories 192 plugin_dirs = plugin_directories 193 194 self.plugin_dirs = plugin_dirs 195 196 self._plugin_status = {} 197 self._downloadable_plugins = [] 198 199 self._plugin_status_changed_callbacks = [] 200 201 self._create_config_section(config)
202
203 - def _create_config_section(self, config):
204 self.config = config.get_section(self.config_section_name) 205 if self.config is None: 206 # There is no section for the plugin registry, create one 207 config.set_section(self.config_section_name, self.default_config, 208 doc=self.config_doc) 209 self.config = config.get_section(self.config_section_name) 210 211 for key, value in self.default_config.iteritems(): 212 self.config.setdefault(key, value)
213 214 # here be the dragons
215 - def _deactivate_dist(self, working_set, name='elisa'):
216 # the list of deactivated distributions 217 dists = [] 218 # the paths containing the deactivated distributions 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 # what we want to do here is this: 229 # working_set.entry_keys[dist.location].remove(dist.key) 230 # unfortunately some versions of pkg_resources don't normalize 231 # path entries so the same distribution could be under different paths 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 # this is the first distribution installed in dist.location that 242 # we deactivate 243 paths.append(dist.location) 244 245 # dist_paths is a list of sys.path-like entries that we need to 246 # remove dist.location from 247 dist_paths = [working_set.entries] 248 249 if working_set.entries is not sys.path: 250 # make sure that we remove the location from sys.path 251 dist_paths.append(sys.path) 252 253 # for uninstalled distributions, we add the location to 254 # elisa.plugins.__path__ (or elisa.__path__ for core) so we need 255 # to remove it here 256 if is_development_egg(dist): 257 self._fix_uninstalled_plugin(dist, 'remove') 258 259 for entries in dist_paths: 260 # remove dist.location from the working set, it will be added 261 # back later when the first distribution in dist.location is 262 # activated. 263 264 try: 265 entries.remove(dist.location) 266 except ValueError: 267 # dist.location is always normalized but "entries" 268 # contains unnormalized paths. This usually happens on 269 # windows. 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
280 - def _fix_uninstalled_plugin(self, dist, action):
281 assert action in ('add', 'remove') 282 283 # dist is an uncompressed egg that contains the modules 284 # directly under dist.location and not under 285 # dist.location/elisa/plugins. With our current layout this 286 # means all the plugins when running elisa uninstalled. 287 self.info('%s is an uninstalled plugin' % dist) 288 289 normalized_location = pkg_resources.normalize_path(dist.location) 290 parent = os.path.dirname(normalized_location) 291 path = None 292 if dist.key == 'elisa': 293 import elisa 294 path = elisa.__path__ 295 else: 296 import elisa.plugins 297 path = elisa.plugins.__path__ 298 299 if action == 'add': 300 if parent not in path: 301 path.append(parent) 302 elif action == 'remove': 303 try: 304 path.remove(parent) 305 except ValueError: 306 pass
307
308 - def _add_gstreamer_path(self, path):
309 # imported here because we may not have it before loading the binary 310 # plugins. 311 import gst 312 registry = gst.registry_get_default() 313 registry.add_path(path) 314 registry.scan_path(path)
315
316 - def _path_hacks_workaround(self):
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 # The way we do it is by moving the python-support dirs to the end of 323 # sys.path. 324 for path in sys.path: 325 if 'python-support' in path: 326 sys.path.remove(path) 327 sys.path.append(path)
328
329 - def load_plugins(self, disabled_plugins=[]):
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 # deactivate the plugins in the current working set so we can activate 349 # new versions in self.plugin_dirs 350 old_dists, paths = self._deactivate_dist(pkg_resources.working_set, 'elisa') 351 352 import elisa.plugins 353 # empty elisa.plugins.__path__, it will be populated again when we add 354 # back the plugins 355 elisa.plugins.__path__ = [] 356 357 # paths contains a list with the paths of the distributions that we 358 # deactivated in deactivated_dist(). When load_plugins() is called for 359 # the first time it contains the path of the plugins installed system 360 # wide (if there is one). 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 # return the priority of a plugin, to sort them on whether they 376 # are binary extensions or not, then considering if they are in 377 # the installed directory or in a user/ELISA_PLUGINS directory. 378 379 # FIXME: what about binary plugins that depend on other binary 380 # plugins? (pgm would depend on gst). 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 # the gstreamer plugin is needed by other binary plugins, 397 # therefore it needs a higher priority 398 priority |= 4 399 400 elif dist.key == 'elisa': 401 # the core should have the highest priority since all plugins 402 # supposedly depend on it. 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 # Determine whether a given distribution is a plugin to be loaded 411 # in Moovida. Such candidates are: 412 # - the core (elisa) 413 # - normal plugins (elisa-plugin-.*) 414 # - binary plugins (e.g. gstreamer, on windows) 415 # We cannot rely on the presence of the native_libs.txt metadata in 416 # the distribution as other completely unrelated distributions may 417 # match this criterion 418 # (see http://bugs.launchpad.net/elisa/+bug/383662). 419 # Note that the _clean_ way to do this would be for all plugins to 420 # ship a specific metadata identifying itself as a Moovida plugin. 421 whitelist = ['gstreamer'] # Add here other binary plugins 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 # add the plugins to the active working set 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
447 - def load_plugin(self, plugin):
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 # Check if the plugin is platform specific. If so, do not load it if 458 # the target platform does not match. 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 # Do some extra work if the plugin is an uncompressed, uninstalled 472 # plugin. 473 if is_development_egg(plugin): 474 self._fix_uninstalled_plugin(plugin, 'add') 475 476 # Add the distribution to the working set. 477 pkg_resources.working_set.add(plugin) 478 479 if plugin.key == 'elisa': 480 # The core doesn't need extra processing (no elisa.plugins path 481 # black magic, doesn't ship gstreamer plugins, no hooks). 482 return 483 484 # We emptied elisa.plugins.__path__ before loading the plugins, add the 485 # current plugin's root if needed. pkg_resources takes care of it for 486 # eggs but for some reason it doesn't for uncompressed plugins. 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 # Plugins can ship gstreamer plugins in a directory called 'gstreamer' 494 # under the top level plugin directory. 495 if plugin.project_name.startswith('elisa-plugin'): 496 # FIXME: handle that in the load hook of the relevant plugins 497 toplevel_directory = get_plugin_toplevel_directory(plugin) 498 # pkg_resources.resource_filename() expects slashes regardless of the 499 # value of os.sep 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 # gstreamer's registry supports unicode, let's use it, 506 # especially for Windows where gst_path can 507 # contain accentuated characters (if home directory 508 # contains some and the gstreamer plugin is located in 509 # home directory). 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 # we don't use ._call_hook because we want this to be blocking 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
527 - def unload_plugin(self, plugin):
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 # First disable the plugin if needed. 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 # TODO: actually unload the plugin. 547 return result 548 549 dfr.addCallback(unload) 550 return dfr 551
552 - def register_plugin_status_changed_callback(self, callback):
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
567 - def unregister_plugin_status_changed_callback(self, callback):
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
579 - def _fire_registered_callbacks(self, result, plugin, status):
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
593 - def enable_plugins(self):
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 # enable_plugin() expects the plugin to be disabled 608 self._plugin_status[plugin_name] = False 609 yield self.enable_plugin(plugin_name)
610 611 return task.coiterate(iterate_plugins()) 612
613 - def enable_plugin(self, plugin_name):
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
659 - def disable_plugin(self, plugin_name, permanent=False):
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
706 - def get_plugins(self):
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
718 - def get_enabled_plugins(self):
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
731 - def get_plugin_names(self):
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
740 - def get_plugin_by_name(self, plugin_name):
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
753 - def get_plugin_metadata(self, plugin):
754 """ 755 Read and populate the metadata of a plugin. 756 757 @param plugin: a plugin 758 @type plugin: L{pkg_resources.Distribution} 759 """ 760 # FIXME: isn't there a simpler way to read the plugin standard's 761 # metadata? 762 metadata = 'PKG-INFO' 763 attributes = {'Author': 'author', 'Summary': 'summary', 764 'License': 'license', 'Description': 'description'} 765 if not plugin.has_metadata(metadata): 766 return 767 lines = plugin.get_metadata_lines(metadata) 768 for line in lines: 769 try: 770 key, value = line.split(':', 1) 771 except ValueError: 772 continue 773 else: 774 value = value.strip() 775 if value == 'UNKNOWN': 776 value = None 777 if key == 'Platform': 778 # There may be several compatible platforms 779 if value is None: 780 plugin.platforms = [] 781 continue 782 try: 783 plugin.platforms.append(value) 784 except AttributeError: 785 plugin.platforms = [value] 786 continue 787 for attr_key, attr in attributes.iteritems(): 788 if key == attr_key: 789 setattr(plugin, attr, value) 790 attributes.pop(key) 791 break
792
793 - def _get(self, uri):
794 # This method is meant to be monkey-patched in unit tests. 795 return get_page(uri)
796
797 - def _deserialize_cache(self, cache):
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
818 - def plugin_cache(self):
819 return _default_plugin_cache
820 821 @property
822 - def plugin_repository(self):
823 return self.config.get('repository')
824
825 - def reload_cache(self):
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
845 - def update_cache(self):
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
882 - def get_downloadable_plugins(self, reload_cache=False):
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
897 - def download_plugin(self, plugin):
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 # Do not trust a plugin without a checksum 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
949 - def install_plugin(self, egg_file, plugin_name):
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 # Copy the egg_file to the local plugins directory if needed 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