| # Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import copy |
| import hashlib |
| import json |
| import string |
| import sys |
| import urllib2 |
| |
| MANIFEST_VERSION = 2 |
| |
| # Some commonly-used key names. |
| ARCHIVES_KEY = 'archives' |
| BUNDLES_KEY = 'bundles' |
| NAME_KEY = 'name' |
| REVISION_KEY = 'revision' |
| VERSION_KEY = 'version' |
| |
| # Valid values for the archive.host_os field |
| HOST_OS_LITERALS = frozenset(['mac', 'win', 'linux', 'all']) |
| |
| # Valid keys for various sdk objects, used for validation. |
| VALID_ARCHIVE_KEYS = frozenset(['host_os', 'size', 'checksum', 'url']) |
| |
| # Valid values for bundle.stability field |
| STABILITY_LITERALS = [ |
| 'obsolete', 'post_stable', 'stable', 'beta', 'dev', 'canary'] |
| |
| # Valid values for bundle-recommended field. |
| YES_NO_LITERALS = ['yes', 'no'] |
| VALID_BUNDLES_KEYS = frozenset([ |
| ARCHIVES_KEY, NAME_KEY, VERSION_KEY, REVISION_KEY, |
| 'description', 'desc_url', 'stability', 'recommended', 'repath', |
| 'sdk_revision' |
| ]) |
| |
| VALID_MANIFEST_KEYS = frozenset(['manifest_version', BUNDLES_KEY]) |
| |
| |
| def GetHostOS(): |
| '''Returns the host_os value that corresponds to the current host OS''' |
| return { |
| 'linux2': 'linux', |
| 'darwin': 'mac', |
| 'cygwin': 'win', |
| 'win32': 'win' |
| }[sys.platform] |
| |
| |
| def DictToJSON(pydict): |
| """Convert a dict to a JSON-formatted string.""" |
| pretty_string = json.dumps(pydict, sort_keys=True, indent=2) |
| # json.dumps sometimes returns trailing whitespace and does not put |
| # a newline at the end. This code fixes these problems. |
| pretty_lines = pretty_string.split('\n') |
| return '\n'.join([line.rstrip() for line in pretty_lines]) + '\n' |
| |
| |
| def DownloadAndComputeHash(from_stream, to_stream=None, progress_func=None): |
| '''Download the archive data from from-stream and generate sha1 and |
| size info. |
| |
| Args: |
| from_stream: An input stream that supports read. |
| to_stream: [optional] the data is written to to_stream if it is |
| provided. |
| progress_func: [optional] A function used to report download progress. If |
| provided, progress_func is called with progress=0 at the |
| beginning of the download, periodically with progress=1 |
| during the download, and progress=100 at the end. |
| |
| Return |
| A tuple (sha1, size) where sha1 is a sha1-hash for the archive data and |
| size is the size of the archive data in bytes.''' |
| # Use a no-op progress function if none is specified. |
| def progress_no_op(progress): |
| pass |
| if not progress_func: |
| progress_func = progress_no_op |
| |
| sha1_hash = hashlib.sha1() |
| size = 0 |
| progress_func(progress=0) |
| while(1): |
| data = from_stream.read(32768) |
| if not data: |
| break |
| sha1_hash.update(data) |
| size += len(data) |
| if to_stream: |
| to_stream.write(data) |
| progress_func(size) |
| |
| progress_func(progress=100) |
| return sha1_hash.hexdigest(), size |
| |
| |
| class Error(Exception): |
| """Generic error/exception for manifest_util module""" |
| pass |
| |
| |
| class Archive(dict): |
| """A placeholder for sdk archive information. We derive Archive from |
| dict so that it is easily serializable. """ |
| |
| def __init__(self, host_os_name): |
| """ Create a new archive for the given host-os name. """ |
| super(Archive, self).__init__() |
| self['host_os'] = host_os_name |
| |
| def CopyFrom(self, src): |
| """Update the content of the archive by copying values from the given |
| dictionary. |
| |
| Args: |
| src: The dictionary whose values must be copied to the archive.""" |
| for key, value in src.items(): |
| self[key] = value |
| |
| def Validate(self, error_on_unknown_keys=False): |
| """Validate the content of the archive object. Raise an Error if |
| an invalid or missing field is found. |
| |
| Args: |
| error_on_unknown_keys: If True, raise an Error when unknown keys are |
| found in the archive. |
| """ |
| host_os = self.get('host_os', None) |
| if host_os and host_os not in HOST_OS_LITERALS: |
| raise Error('Invalid host-os name in archive') |
| # Ensure host_os has a valid string. We'll use it for pretty printing. |
| if not host_os: |
| host_os = 'all (default)' |
| if not self.get('url', None): |
| raise Error('Archive "%s" has no URL' % host_os) |
| if not self.get('size', None): |
| raise Error('Archive "%s" has no size' % host_os) |
| checksum = self.get('checksum', None) |
| if not checksum: |
| raise Error('Archive "%s" has no checksum' % host_os) |
| elif not isinstance(checksum, dict): |
| raise Error('Archive "%s" has a checksum, but it is not a dict' % host_os) |
| elif not len(checksum): |
| raise Error('Archive "%s" has an empty checksum dict' % host_os) |
| # Verify that all key names are valid. |
| if error_on_unknown_keys: |
| for key in self: |
| if key not in VALID_ARCHIVE_KEYS: |
| raise Error('Archive "%s" has invalid attribute "%s"' % ( |
| host_os, key)) |
| |
| def UpdateVitals(self, revision): |
| """Update the size and checksum information for this archive |
| based on the content currently at the URL. |
| |
| This allows the template manifest to be maintained without |
| the need to size and checksums to be present. |
| """ |
| template = string.Template(self['url']) |
| self['url'] = template.substitute({'revision': revision}) |
| from_stream = urllib2.urlopen(self['url']) |
| sha1_hash, size = DownloadAndComputeHash(from_stream) |
| self['size'] = size |
| self['checksum'] = { 'sha1': sha1_hash } |
| |
| def __getattr__(self, name): |
| """Retrieve values from this dict using attributes. |
| |
| This allows for foo.bar instead of foo['bar']. |
| |
| Args: |
| name: the name of the key, 'bar' in the example above. |
| Returns: |
| The value associated with that key.""" |
| if name not in self: |
| raise AttributeError(name) |
| # special case, self.checksum returns the sha1, not the checksum dict. |
| if name == 'checksum': |
| return self.GetChecksum() |
| return self.__getitem__(name) |
| |
| def __setattr__(self, name, value): |
| """Set values in this dict using attributes. |
| |
| This allows for foo.bar instead of foo['bar']. |
| |
| Args: |
| name: The name of the key, 'bar' in the example above. |
| value: The value to associate with that key.""" |
| # special case, self.checksum returns the sha1, not the checksum dict. |
| if name == 'checksum': |
| self.setdefault('checksum', {})['sha1'] = value |
| return |
| return self.__setitem__(name, value) |
| |
| def GetChecksum(self, hash_type='sha1'): |
| """Returns a given cryptographic checksum of the archive""" |
| return self['checksum'][hash_type] |
| |
| |
| class Bundle(dict): |
| """A placeholder for sdk bundle information. We derive Bundle from |
| dict so that it is easily serializable.""" |
| |
| def __init__(self, obj): |
| """ Create a new bundle with the given bundle name.""" |
| if isinstance(obj, str) or isinstance(obj, unicode): |
| dict.__init__(self, [(ARCHIVES_KEY, []), (NAME_KEY, obj)]) |
| else: |
| dict.__init__(self, obj) |
| |
| def MergeWithBundle(self, bundle): |
| """Merge this bundle with |bundle|. |
| |
| Merges dict in |bundle| with this one in such a way that keys are not |
| duplicated: the values of the keys in |bundle| take precedence in the |
| resulting dictionary. |
| |
| Archives in |bundle| will be appended to archives in self. |
| |
| Args: |
| bundle: The other bundle. Must be a dict. |
| """ |
| assert self is not bundle |
| |
| for k, v in bundle.iteritems(): |
| if k == ARCHIVES_KEY: |
| for archive in v: |
| self.get(k, []).append(archive) |
| else: |
| self[k] = v |
| |
| def __str__(self): |
| return self.GetDataAsString() |
| |
| def GetDataAsString(self): |
| """Returns the JSON bundle object, pretty-printed""" |
| return DictToJSON(self) |
| |
| def LoadDataFromString(self, json_string): |
| """Load a JSON bundle string. Raises an exception if json_string |
| is not well-formed JSON. |
| |
| Args: |
| json_string: a JSON-formatted string containing the bundle |
| """ |
| self.CopyFrom(json.loads(json_string)) |
| |
| def CopyFrom(self, source): |
| """Update the content of the bundle by copying values from the given |
| dictionary. |
| |
| Args: |
| source: The dictionary whose values must be copied to the bundle.""" |
| for key, value in source.items(): |
| if key == ARCHIVES_KEY: |
| archives = [] |
| for a in value: |
| new_archive = Archive(a['host_os']) |
| new_archive.CopyFrom(a) |
| archives.append(new_archive) |
| self[ARCHIVES_KEY] = archives |
| else: |
| self[key] = value |
| |
| def Validate(self, add_missing_info=False, error_on_unknown_keys=False): |
| """Validate the content of the bundle. Raise an Error if an invalid or |
| missing field is found. |
| |
| Args: |
| error_on_unknown_keys: If True, raise an Error when unknown keys are |
| found in the bundle. |
| """ |
| # Check required fields. |
| if not self.get(NAME_KEY): |
| raise Error('Bundle has no name') |
| if self.get(REVISION_KEY) == None: |
| raise Error('Bundle "%s" is missing a revision number' % self[NAME_KEY]) |
| if self.get(VERSION_KEY) == None: |
| raise Error('Bundle "%s" is missing a version number' % self[NAME_KEY]) |
| if not self.get('description'): |
| raise Error('Bundle "%s" is missing a description' % self[NAME_KEY]) |
| if not self.get('stability'): |
| raise Error('Bundle "%s" is missing stability info' % self[NAME_KEY]) |
| if self.get('recommended') == None: |
| raise Error('Bundle "%s" is missing the recommended field' % |
| self[NAME_KEY]) |
| # Check specific values |
| if self['stability'] not in STABILITY_LITERALS: |
| raise Error('Bundle "%s" has invalid stability field: "%s"' % |
| (self[NAME_KEY], self['stability'])) |
| if self['recommended'] not in YES_NO_LITERALS: |
| raise Error( |
| 'Bundle "%s" has invalid recommended field: "%s"' % |
| (self[NAME_KEY], self['recommended'])) |
| # Verify that all key names are valid. |
| if error_on_unknown_keys: |
| for key in self: |
| if key not in VALID_BUNDLES_KEYS: |
| raise Error('Bundle "%s" has invalid attribute "%s"' % |
| (self[NAME_KEY], key)) |
| # Validate the archives |
| for archive in self[ARCHIVES_KEY]: |
| if add_missing_info and 'size' not in archive: |
| archive.UpdateVitals(self[REVISION_KEY]) |
| archive.Validate(error_on_unknown_keys) |
| |
| def GetArchive(self, host_os_name): |
| """Retrieve the archive for the given host os. |
| |
| Args: |
| host_os_name: name of host os whose archive must be retrieved. |
| Return: |
| An Archive instance or None if it doesn't exist.""" |
| for archive in self[ARCHIVES_KEY]: |
| if archive.host_os == host_os_name or archive.host_os == 'all': |
| return archive |
| return None |
| |
| def GetHostOSArchive(self): |
| """Retrieve the archive for the current host os.""" |
| return self.GetArchive(GetHostOS()) |
| |
| def GetHostOSArchives(self): |
| """Retrieve all archives for the current host os, or marked all. |
| """ |
| return [archive for archive in self.GetArchives() |
| if archive.host_os in (GetHostOS(), 'all')] |
| |
| def GetArchives(self): |
| """Returns all the archives in this bundle""" |
| return self[ARCHIVES_KEY] |
| |
| def AddArchive(self, archive): |
| """Add an archive to this bundle.""" |
| self[ARCHIVES_KEY].append(archive) |
| |
| def RemoveAllArchives(self): |
| """Remove all archives from this Bundle.""" |
| del self[ARCHIVES_KEY][:] |
| |
| def RemoveAllArchivesForHostOS(self, host_os_name): |
| """Remove an archive from this Bundle.""" |
| if host_os_name == 'all': |
| del self[ARCHIVES_KEY][:] |
| else: |
| for i, archive in enumerate(self[ARCHIVES_KEY]): |
| if archive.host_os == host_os_name: |
| del self[ARCHIVES_KEY][i] |
| |
| def __getattr__(self, name): |
| """Retrieve values from this dict using attributes. |
| |
| This allows for foo.bar instead of foo['bar']. |
| |
| Args: |
| name: the name of the key, 'bar' in the example above. |
| Returns: |
| The value associated with that key.""" |
| if name not in self: |
| raise AttributeError(name) |
| return self.__getitem__(name) |
| |
| def __setattr__(self, name, value): |
| """Set values in this dict using attributes. |
| |
| This allows for foo.bar instead of foo['bar']. |
| |
| Args: |
| name: The name of the key, 'bar' in the example above. |
| value: The value to associate with that key.""" |
| self.__setitem__(name, value) |
| |
| def __eq__(self, bundle): |
| """Test if two bundles are equal. |
| |
| Normally the default comparison for two dicts is fine, but in this case we |
| don't care about the list order of the archives. |
| |
| Args: |
| bundle: The other bundle to compare against. |
| Returns: |
| True if the bundles are equal.""" |
| if not isinstance(bundle, Bundle): |
| return False |
| if len(self.keys()) != len(bundle.keys()): |
| return False |
| for key in self.keys(): |
| if key not in bundle: |
| return False |
| # special comparison for ARCHIVE_KEY because we don't care about the list |
| # ordering. |
| if key == ARCHIVES_KEY: |
| if len(self[key]) != len(bundle[key]): |
| return False |
| for archive in self[key]: |
| if archive != bundle.GetArchive(archive.host_os): |
| return False |
| elif self[key] != bundle[key]: |
| return False |
| return True |
| |
| def __ne__(self, bundle): |
| """Test if two bundles are unequal. |
| |
| See __eq__ for more info.""" |
| return not self.__eq__(bundle) |
| |
| |
| class SDKManifest(object): |
| """This class contains utilities for manipulation an SDK manifest string |
| |
| For ease of unit-testing, this class should not contain any file I/O. |
| """ |
| |
| def __init__(self): |
| """Create a new SDKManifest object with default contents""" |
| self._manifest_data = { |
| "manifest_version": MANIFEST_VERSION, |
| "bundles": [], |
| } |
| |
| def Validate(self, add_missing_info=False): |
| """Validate the Manifest file and raises an exception for problems""" |
| # Validate the manifest top level |
| if self._manifest_data["manifest_version"] > MANIFEST_VERSION: |
| raise Error("Manifest version too high: %s" % |
| self._manifest_data["manifest_version"]) |
| # Verify that all key names are valid. |
| for key in self._manifest_data: |
| if key not in VALID_MANIFEST_KEYS: |
| raise Error('Manifest has invalid attribute "%s"' % key) |
| # Validate each bundle |
| for bundle in self._manifest_data[BUNDLES_KEY]: |
| bundle.Validate(add_missing_info) |
| |
| def GetBundle(self, name): |
| """Get a bundle from the array of bundles. |
| |
| Args: |
| name: the name of the bundle to return. |
| Return: |
| The first bundle with the given name, or None if it is not found.""" |
| if not BUNDLES_KEY in self._manifest_data: |
| return None |
| bundles = [bundle for bundle in self._manifest_data[BUNDLES_KEY] |
| if bundle[NAME_KEY] == name] |
| if len(bundles) > 1: |
| sys.stderr.write("WARNING: More than one bundle with name" |
| "'%s' exists.\n" % name) |
| return bundles[0] if len(bundles) > 0 else None |
| |
| def GetBundles(self): |
| """Return all the bundles in the manifest.""" |
| return self._manifest_data[BUNDLES_KEY] |
| |
| def SetBundle(self, new_bundle): |
| """Add or replace a bundle in the manifest. |
| |
| Note: If a bundle in the manifest already exists with this name, it will be |
| overwritten with a copy of this bundle, at the same index as the original. |
| |
| Args: |
| bundle: The bundle. |
| """ |
| name = new_bundle[NAME_KEY] |
| bundles = self.GetBundles() |
| new_bundle_copy = copy.deepcopy(new_bundle) |
| for i, bundle in enumerate(bundles): |
| if bundle[NAME_KEY] == name: |
| bundles[i] = new_bundle_copy |
| return |
| # Bundle not already in list, append it. |
| bundles.append(new_bundle_copy) |
| |
| def RemoveBundle(self, name): |
| """Remove a bundle by name. |
| |
| Args: |
| name: the name of the bundle to remove. |
| Return: |
| True if the bundle was removed, False if there is no bundle with that |
| name. |
| """ |
| if not BUNDLES_KEY in self._manifest_data: |
| return False |
| bundles = self._manifest_data[BUNDLES_KEY] |
| for i, bundle in enumerate(bundles): |
| if bundle[NAME_KEY] == name: |
| del bundles[i] |
| return True |
| return False |
| |
| def BundleNeedsUpdate(self, bundle): |
| """Decides if a bundle needs to be updated. |
| |
| A bundle needs to be updated if it is not installed (doesn't exist in this |
| manifest file) or if its revision is later than the revision in this file. |
| |
| Args: |
| bundle: The Bundle to test. |
| Returns: |
| True if Bundle needs to be updated. |
| """ |
| if NAME_KEY not in bundle: |
| raise KeyError("Bundle must have a 'name' key.") |
| local_bundle = self.GetBundle(bundle[NAME_KEY]) |
| return (local_bundle == None) or ( |
| (local_bundle[VERSION_KEY], local_bundle[REVISION_KEY]) < |
| (bundle[VERSION_KEY], bundle[REVISION_KEY])) |
| |
| def MergeBundle(self, bundle, allow_existing=True): |
| """Merge a Bundle into this manifest. |
| |
| The new bundle is added if not present, or merged into the existing bundle. |
| |
| Args: |
| bundle: The bundle to merge. |
| """ |
| if NAME_KEY not in bundle: |
| raise KeyError("Bundle must have a 'name' key.") |
| local_bundle = self.GetBundle(bundle.name) |
| if not local_bundle: |
| self.SetBundle(bundle) |
| else: |
| if not allow_existing: |
| raise Error('cannot merge manifest bundle \'%s\', it already exists' |
| % bundle.name) |
| local_bundle.MergeWithBundle(bundle) |
| |
| def MergeManifest(self, manifest): |
| '''Merge another manifest into this manifest, disallowing overriding. |
| |
| Args |
| manifest: The manifest to merge. |
| ''' |
| for bundle in manifest.GetBundles(): |
| self.MergeBundle(bundle, allow_existing=False) |
| |
| def FilterBundles(self, predicate): |
| """Filter the list of bundles by |predicate|. |
| |
| For all bundles in this manifest, if predicate(bundle) is False, the bundle |
| is removed from the manifest. |
| |
| Args: |
| predicate: a function that take a bundle and returns whether True to keep |
| it or False to remove it. |
| """ |
| self._manifest_data[BUNDLES_KEY] = filter(predicate, self.GetBundles()) |
| |
| def LoadDataFromString(self, json_string, add_missing_info=False): |
| """Load a JSON manifest string. Raises an exception if json_string |
| is not well-formed JSON. |
| |
| Args: |
| json_string: a JSON-formatted string containing the previous manifest |
| all_hosts: True indicates that we should load bundles for all hosts. |
| False (default) says to only load bundles for the current host""" |
| new_manifest = json.loads(json_string) |
| for key, value in new_manifest.items(): |
| if key == BUNDLES_KEY: |
| # Remap each bundle in |value| to a Bundle instance |
| bundles = [] |
| for b in value: |
| new_bundle = Bundle(b[NAME_KEY]) |
| new_bundle.CopyFrom(b) |
| bundles.append(new_bundle) |
| self._manifest_data[key] = bundles |
| else: |
| self._manifest_data[key] = value |
| self.Validate(add_missing_info) |
| |
| def __str__(self): |
| return self.GetDataAsString() |
| |
| def __eq__(self, other): |
| # Access to protected member _manifest_data of a client class |
| # pylint: disable=W0212 |
| if (self._manifest_data['manifest_version'] != |
| other._manifest_data['manifest_version']): |
| return False |
| |
| self_bundle_names = set(b.name for b in self.GetBundles()) |
| other_bundle_names = set(b.name for b in other.GetBundles()) |
| if self_bundle_names != other_bundle_names: |
| return False |
| |
| for bundle_name in self_bundle_names: |
| if self.GetBundle(bundle_name) != other.GetBundle(bundle_name): |
| return False |
| |
| return True |
| |
| def __ne__(self, other): |
| return not (self == other) |
| |
| def GetDataAsString(self): |
| """Returns the current JSON manifest object, pretty-printed""" |
| return DictToJSON(self._manifest_data) |