summaryrefslogtreecommitdiffstats
path: root/testing/mozbase/mozprofile/mozprofile/addons.py
diff options
context:
space:
mode:
Diffstat (limited to 'testing/mozbase/mozprofile/mozprofile/addons.py')
-rw-r--r--testing/mozbase/mozprofile/mozprofile/addons.py410
1 files changed, 410 insertions, 0 deletions
diff --git a/testing/mozbase/mozprofile/mozprofile/addons.py b/testing/mozbase/mozprofile/mozprofile/addons.py
new file mode 100644
index 000000000..e96fd6b36
--- /dev/null
+++ b/testing/mozbase/mozprofile/mozprofile/addons.py
@@ -0,0 +1,410 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+import shutil
+import sys
+import tempfile
+import urllib2
+import zipfile
+from xml.dom import minidom
+
+import mozfile
+from mozlog.unstructured import getLogger
+
+# Needed for the AMO's rest API -
+# https://developer.mozilla.org/en/addons.mozilla.org_%28AMO%29_API_Developers%27_Guide/The_generic_AMO_API
+AMO_API_VERSION = "1.5"
+
+# Logger for 'mozprofile.addons' module
+module_logger = getLogger(__name__)
+
+
+class AddonFormatError(Exception):
+ """Exception for not well-formed add-on manifest files"""
+
+
+class AddonManager(object):
+ """
+ Handles all operations regarding addons in a profile including:
+ installing and cleaning addons
+ """
+
+ def __init__(self, profile, restore=True):
+ """
+ :param profile: the path to the profile for which we install addons
+ :param restore: whether to reset to the previous state on instance garbage collection
+ """
+ self.profile = profile
+ self.restore = restore
+
+ # Initialize all class members
+ self._internal_init()
+
+ def _internal_init(self):
+ """Internal: Initialize all class members to their default value"""
+
+ # Add-ons installed; needed for cleanup
+ self._addons = []
+
+ # Backup folder for already existing addons
+ self.backup_dir = None
+
+ # Add-ons downloaded and which have to be removed from the file system
+ self.downloaded_addons = []
+
+ # Information needed for profile reset (see http://bit.ly/17JesUf)
+ self.installed_addons = []
+ self.installed_manifests = []
+
+ def __del__(self):
+ # reset to pre-instance state
+ if self.restore:
+ self.clean()
+
+ def clean(self):
+ """Clean up addons in the profile."""
+
+ # Remove all add-ons installed
+ for addon in self._addons:
+ # TODO (bug 934642)
+ # Once we have a proper handling of add-ons we should kill the id
+ # from self._addons once the add-on is removed. For now lets forget
+ # about the exception
+ try:
+ self.remove_addon(addon)
+ except IOError:
+ pass
+
+ # Remove all downloaded add-ons
+ for addon in self.downloaded_addons:
+ mozfile.remove(addon)
+
+ # restore backups
+ if self.backup_dir and os.path.isdir(self.backup_dir):
+ extensions_path = os.path.join(self.profile, 'extensions', 'staged')
+
+ for backup in os.listdir(self.backup_dir):
+ backup_path = os.path.join(self.backup_dir, backup)
+ shutil.move(backup_path, extensions_path)
+
+ if not os.listdir(self.backup_dir):
+ mozfile.remove(self.backup_dir)
+
+ # reset instance variables to defaults
+ self._internal_init()
+
+ @classmethod
+ def download(self, url, target_folder=None):
+ """
+ Downloads an add-on from the specified URL to the target folder
+
+ :param url: URL of the add-on (XPI file)
+ :param target_folder: Folder to store the XPI file in
+
+ """
+ response = urllib2.urlopen(url)
+ fd, path = tempfile.mkstemp(suffix='.xpi')
+ os.write(fd, response.read())
+ os.close(fd)
+
+ if not self.is_addon(path):
+ mozfile.remove(path)
+ raise AddonFormatError('Not a valid add-on: %s' % url)
+
+ # Give the downloaded file a better name by using the add-on id
+ details = self.addon_details(path)
+ new_path = path.replace('.xpi', '_%s.xpi' % details.get('id'))
+
+ # Move the add-on to the target folder if requested
+ if target_folder:
+ new_path = os.path.join(target_folder, os.path.basename(new_path))
+
+ os.rename(path, new_path)
+
+ return new_path
+
+ def get_addon_path(self, addon_id):
+ """Returns the path to the installed add-on
+
+ :param addon_id: id of the add-on to retrieve the path from
+ """
+ # By default we should expect add-ons being located under the
+ # extensions folder. Only if the application hasn't been run and
+ # installed the add-ons yet, it will be located under 'staged'.
+ # Also add-ons could have been unpacked by the application.
+ extensions_path = os.path.join(self.profile, 'extensions')
+ paths = [os.path.join(extensions_path, addon_id),
+ os.path.join(extensions_path, addon_id + '.xpi'),
+ os.path.join(extensions_path, 'staged', addon_id),
+ os.path.join(extensions_path, 'staged', addon_id + '.xpi')]
+ for path in paths:
+ if os.path.exists(path):
+ return path
+
+ raise IOError('Add-on not found: %s' % addon_id)
+
+ @classmethod
+ def is_addon(self, addon_path):
+ """
+ Checks if the given path is a valid addon
+
+ :param addon_path: path to the add-on directory or XPI
+ """
+ try:
+ self.addon_details(addon_path)
+ return True
+ except AddonFormatError:
+ return False
+
+ def install_addons(self, addons=None, manifests=None):
+ """
+ Installs all types of addons
+
+ :param addons: a list of addon paths to install
+ :param manifest: a list of addon manifests to install
+ """
+
+ # install addon paths
+ if addons:
+ if isinstance(addons, basestring):
+ addons = [addons]
+ for addon in set(addons):
+ self.install_from_path(addon)
+
+ # install addon manifests
+ if manifests:
+ if isinstance(manifests, basestring):
+ manifests = [manifests]
+ for manifest in manifests:
+ self.install_from_manifest(manifest)
+
+ def install_from_manifest(self, filepath):
+ """
+ Installs addons from a manifest
+ :param filepath: path to the manifest of addons to install
+ """
+ try:
+ from manifestparser import ManifestParser
+ except ImportError:
+ module_logger.critical(
+ "Installing addons from manifest requires the"
+ " manifestparser package to be installed.")
+ raise
+
+ manifest = ManifestParser()
+ manifest.read(filepath)
+ addons = manifest.get()
+
+ for addon in addons:
+ if '://' in addon['path'] or os.path.exists(addon['path']):
+ self.install_from_path(addon['path'])
+ continue
+
+ # No path specified, try to grab it off AMO
+ locale = addon.get('amo_locale', 'en_US')
+ query = 'https://services.addons.mozilla.org/' + locale + '/firefox/api/' \
+ + AMO_API_VERSION + '/'
+ if 'amo_id' in addon:
+ # this query grabs information on the addon base on its id
+ query += 'addon/' + addon['amo_id']
+ else:
+ # this query grabs information on the first addon returned from a search
+ query += 'search/' + addon['name'] + '/default/1'
+ install_path = AddonManager.get_amo_install_path(query)
+ self.install_from_path(install_path)
+
+ self.installed_manifests.append(filepath)
+
+ @classmethod
+ def get_amo_install_path(self, query):
+ """
+ Get the addon xpi install path for the specified AMO query.
+
+ :param query: query-documentation_
+
+ .. _query-documentation: https://developer.mozilla.org/en/addons.mozilla.org_%28AMO%29_API_Developers%27_Guide/The_generic_AMO_API # noqa
+ """
+ response = urllib2.urlopen(query)
+ dom = minidom.parseString(response.read())
+ for node in dom.getElementsByTagName('install')[0].childNodes:
+ if node.nodeType == node.TEXT_NODE:
+ return node.data
+
+ @classmethod
+ def addon_details(cls, addon_path):
+ """
+ Returns a dictionary of details about the addon.
+
+ :param addon_path: path to the add-on directory or XPI
+
+ Returns::
+
+ {'id': u'rainbow@colors.org', # id of the addon
+ 'version': u'1.4', # version of the addon
+ 'name': u'Rainbow', # name of the addon
+ 'unpack': False } # whether to unpack the addon
+ """
+
+ details = {
+ 'id': None,
+ 'unpack': False,
+ 'name': None,
+ 'version': None
+ }
+
+ def get_namespace_id(doc, url):
+ attributes = doc.documentElement.attributes
+ namespace = ""
+ for i in range(attributes.length):
+ if attributes.item(i).value == url:
+ if ":" in attributes.item(i).name:
+ # If the namespace is not the default one remove 'xlmns:'
+ namespace = attributes.item(i).name.split(':')[1] + ":"
+ break
+ return namespace
+
+ def get_text(element):
+ """Retrieve the text value of a given node"""
+ rc = []
+ for node in element.childNodes:
+ if node.nodeType == node.TEXT_NODE:
+ rc.append(node.data)
+ return ''.join(rc).strip()
+
+ if not os.path.exists(addon_path):
+ raise IOError('Add-on path does not exist: %s' % addon_path)
+
+ try:
+ if zipfile.is_zipfile(addon_path):
+ # Bug 944361 - We cannot use 'with' together with zipFile because
+ # it will cause an exception thrown in Python 2.6.
+ try:
+ compressed_file = zipfile.ZipFile(addon_path, 'r')
+ manifest = compressed_file.read('install.rdf')
+ finally:
+ compressed_file.close()
+ elif os.path.isdir(addon_path):
+ with open(os.path.join(addon_path, 'install.rdf'), 'r') as f:
+ manifest = f.read()
+ else:
+ raise IOError('Add-on path is neither an XPI nor a directory: %s' % addon_path)
+ except (IOError, KeyError) as e:
+ raise AddonFormatError(str(e)), None, sys.exc_info()[2]
+
+ try:
+ doc = minidom.parseString(manifest)
+
+ # Get the namespaces abbreviations
+ em = get_namespace_id(doc, 'http://www.mozilla.org/2004/em-rdf#')
+ rdf = get_namespace_id(doc, 'http://www.w3.org/1999/02/22-rdf-syntax-ns#')
+
+ description = doc.getElementsByTagName(rdf + 'Description').item(0)
+ for entry, value in description.attributes.items():
+ # Remove the namespace prefix from the tag for comparison
+ entry = entry.replace(em, "")
+ if entry in details.keys():
+ details.update({entry: value})
+ for node in description.childNodes:
+ # Remove the namespace prefix from the tag for comparison
+ entry = node.nodeName.replace(em, "")
+ if entry in details.keys():
+ details.update({entry: get_text(node)})
+ except Exception as e:
+ raise AddonFormatError(str(e)), None, sys.exc_info()[2]
+
+ # turn unpack into a true/false value
+ if isinstance(details['unpack'], basestring):
+ details['unpack'] = details['unpack'].lower() == 'true'
+
+ # If no ID is set, the add-on is invalid
+ if details.get('id') is None:
+ raise AddonFormatError('Add-on id could not be found.')
+
+ return details
+
+ def install_from_path(self, path, unpack=False):
+ """
+ Installs addon from a filepath, url or directory of addons in the profile.
+
+ :param path: url, path to .xpi, or directory of addons
+ :param unpack: whether to unpack unless specified otherwise in the install.rdf
+ """
+
+ # if the addon is a URL, download it
+ # note that this won't work with protocols urllib2 doesn't support
+ if mozfile.is_url(path):
+ path = self.download(path)
+ self.downloaded_addons.append(path)
+
+ addons = [path]
+
+ # if path is not an add-on, try to install all contained add-ons
+ try:
+ self.addon_details(path)
+ except AddonFormatError as e:
+ module_logger.warning('Could not install %s: %s' % (path, str(e)))
+
+ # If the path doesn't exist, then we don't really care, just return
+ if not os.path.isdir(path):
+ return
+
+ addons = [os.path.join(path, x) for x in os.listdir(path) if
+ self.is_addon(os.path.join(path, x))]
+ addons.sort()
+
+ # install each addon
+ for addon in addons:
+ # determine the addon id
+ addon_details = self.addon_details(addon)
+ addon_id = addon_details.get('id')
+
+ # if the add-on has to be unpacked force it now
+ # note: we might want to let Firefox do it in case of addon details
+ orig_path = None
+ if os.path.isfile(addon) and (unpack or addon_details['unpack']):
+ orig_path = addon
+ addon = tempfile.mkdtemp()
+ mozfile.extract(orig_path, addon)
+
+ # copy the addon to the profile
+ extensions_path = os.path.join(self.profile, 'extensions', 'staged')
+ addon_path = os.path.join(extensions_path, addon_id)
+
+ if os.path.isfile(addon):
+ addon_path += '.xpi'
+
+ # move existing xpi file to backup location to restore later
+ if os.path.exists(addon_path):
+ self.backup_dir = self.backup_dir or tempfile.mkdtemp()
+ shutil.move(addon_path, self.backup_dir)
+
+ # copy new add-on to the extension folder
+ if not os.path.exists(extensions_path):
+ os.makedirs(extensions_path)
+ shutil.copy(addon, addon_path)
+ else:
+ # move existing folder to backup location to restore later
+ if os.path.exists(addon_path):
+ self.backup_dir = self.backup_dir or tempfile.mkdtemp()
+ shutil.move(addon_path, self.backup_dir)
+
+ # copy new add-on to the extension folder
+ shutil.copytree(addon, addon_path, symlinks=True)
+
+ # if we had to extract the addon, remove the temporary directory
+ if orig_path:
+ mozfile.remove(addon)
+ addon = orig_path
+
+ self._addons.append(addon_id)
+ self.installed_addons.append(addon)
+
+ def remove_addon(self, addon_id):
+ """Remove the add-on as specified by the id
+
+ :param addon_id: id of the add-on to be removed
+ """
+ path = self.get_addon_path(addon_id)
+ mozfile.remove(path)