1 # Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
14 # Some commonly-used key names.
15 ARCHIVES_KEY
= 'archives'
16 BUNDLES_KEY
= 'bundles'
18 REVISION_KEY
= 'revision'
19 VERSION_KEY
= 'version'
21 # Valid values for the archive.host_os field
22 HOST_OS_LITERALS
= frozenset(['mac', 'win', 'linux', 'all'])
24 # Valid keys for various sdk objects, used for validation.
25 VALID_ARCHIVE_KEYS
= frozenset(['host_os', 'size', 'checksum', 'url'])
27 # Valid values for bundle.stability field
28 STABILITY_LITERALS
= [
29 'obsolete', 'post_stable', 'stable', 'beta', 'dev', 'canary']
31 # Valid values for bundle-recommended field.
32 YES_NO_LITERALS
= ['yes', 'no']
33 VALID_BUNDLES_KEYS
= frozenset([
34 ARCHIVES_KEY
, NAME_KEY
, VERSION_KEY
, REVISION_KEY
,
35 'description', 'desc_url', 'stability', 'recommended', 'repath',
39 VALID_MANIFEST_KEYS
= frozenset(['manifest_version', BUNDLES_KEY
])
43 '''Returns the host_os value that corresponds to the current host OS'''
52 def DictToJSON(pydict
):
53 """Convert a dict to a JSON-formatted string."""
54 pretty_string
= json
.dumps(pydict
, sort_keys
=True, indent
=2)
55 # json.dumps sometimes returns trailing whitespace and does not put
56 # a newline at the end. This code fixes these problems.
57 pretty_lines
= pretty_string
.split('\n')
58 return '\n'.join([line
.rstrip() for line
in pretty_lines
]) + '\n'
61 def DownloadAndComputeHash(from_stream
, to_stream
=None, progress_func
=None):
62 '''Download the archive data from from-stream and generate sha1 and
66 from_stream: An input stream that supports read.
67 to_stream: [optional] the data is written to to_stream if it is
69 progress_func: [optional] A function used to report download progress. If
70 provided, progress_func is called with progress=0 at the
71 beginning of the download, periodically with progress=1
72 during the download, and progress=100 at the end.
75 A tuple (sha1, size) where sha1 is a sha1-hash for the archive data and
76 size is the size of the archive data in bytes.'''
77 # Use a no-op progress function if none is specified.
78 def progress_no_op(progress
):
81 progress_func
= progress_no_op
83 sha1_hash
= hashlib
.sha1()
85 progress_func(progress
=0)
87 data
= from_stream
.read(32768)
90 sha1_hash
.update(data
)
96 progress_func(progress
=100)
97 return sha1_hash
.hexdigest(), size
100 class Error(Exception):
101 """Generic error/exception for manifest_util module"""
106 """A placeholder for sdk archive information. We derive Archive from
107 dict so that it is easily serializable. """
109 def __init__(self
, host_os_name
):
110 """ Create a new archive for the given host-os name. """
111 super(Archive
, self
).__init
__()
112 self
['host_os'] = host_os_name
114 def CopyFrom(self
, src
):
115 """Update the content of the archive by copying values from the given
119 src: The dictionary whose values must be copied to the archive."""
120 for key
, value
in src
.items():
123 def Validate(self
, error_on_unknown_keys
=False):
124 """Validate the content of the archive object. Raise an Error if
125 an invalid or missing field is found.
128 error_on_unknown_keys: If True, raise an Error when unknown keys are
129 found in the archive.
131 host_os
= self
.get('host_os', None)
132 if host_os
and host_os
not in HOST_OS_LITERALS
:
133 raise Error('Invalid host-os name in archive')
134 # Ensure host_os has a valid string. We'll use it for pretty printing.
136 host_os
= 'all (default)'
137 if not self
.get('url', None):
138 raise Error('Archive "%s" has no URL' % host_os
)
139 if not self
.get('size', None):
140 raise Error('Archive "%s" has no size' % host_os
)
141 checksum
= self
.get('checksum', None)
143 raise Error('Archive "%s" has no checksum' % host_os
)
144 elif not isinstance(checksum
, dict):
145 raise Error('Archive "%s" has a checksum, but it is not a dict' % host_os
)
146 elif not len(checksum
):
147 raise Error('Archive "%s" has an empty checksum dict' % host_os
)
148 # Verify that all key names are valid.
149 if error_on_unknown_keys
:
151 if key
not in VALID_ARCHIVE_KEYS
:
152 raise Error('Archive "%s" has invalid attribute "%s"' % (
155 def UpdateVitals(self
, revision
):
156 """Update the size and checksum information for this archive
157 based on the content currently at the URL.
159 This allows the template manifest to be maintained without
160 the need to size and checksums to be present.
162 template
= string
.Template(self
['url'])
163 self
['url'] = template
.substitute({'revision': revision
})
164 from_stream
= urllib2
.urlopen(self
['url'])
165 sha1_hash
, size
= DownloadAndComputeHash(from_stream
)
167 self
['checksum'] = { 'sha1': sha1_hash
}
169 def __getattr__(self
, name
):
170 """Retrieve values from this dict using attributes.
172 This allows for foo.bar instead of foo['bar'].
175 name: the name of the key, 'bar' in the example above.
177 The value associated with that key."""
179 raise AttributeError(name
)
180 # special case, self.checksum returns the sha1, not the checksum dict.
181 if name
== 'checksum':
182 return self
.GetChecksum()
183 return self
.__getitem
__(name
)
185 def __setattr__(self
, name
, value
):
186 """Set values in this dict using attributes.
188 This allows for foo.bar instead of foo['bar'].
191 name: The name of the key, 'bar' in the example above.
192 value: The value to associate with that key."""
193 # special case, self.checksum returns the sha1, not the checksum dict.
194 if name
== 'checksum':
195 self
.setdefault('checksum', {})['sha1'] = value
197 return self
.__setitem
__(name
, value
)
199 def GetChecksum(self
, hash_type
='sha1'):
200 """Returns a given cryptographic checksum of the archive"""
201 return self
['checksum'][hash_type
]
205 """A placeholder for sdk bundle information. We derive Bundle from
206 dict so that it is easily serializable."""
208 def __init__(self
, obj
):
209 """ Create a new bundle with the given bundle name."""
210 if isinstance(obj
, str) or isinstance(obj
, unicode):
211 dict.__init
__(self
, [(ARCHIVES_KEY
, []), (NAME_KEY
, obj
)])
213 dict.__init
__(self
, obj
)
215 def MergeWithBundle(self
, bundle
):
216 """Merge this bundle with |bundle|.
218 Merges dict in |bundle| with this one in such a way that keys are not
219 duplicated: the values of the keys in |bundle| take precedence in the
220 resulting dictionary.
222 Archives in |bundle| will be appended to archives in self.
225 bundle: The other bundle. Must be a dict.
227 assert self
is not bundle
229 for k
, v
in bundle
.iteritems():
230 if k
== ARCHIVES_KEY
:
232 self
.get(k
, []).append(archive
)
237 return self
.GetDataAsString()
239 def GetDataAsString(self
):
240 """Returns the JSON bundle object, pretty-printed"""
241 return DictToJSON(self
)
243 def LoadDataFromString(self
, json_string
):
244 """Load a JSON bundle string. Raises an exception if json_string
245 is not well-formed JSON.
248 json_string: a JSON-formatted string containing the bundle
250 self
.CopyFrom(json
.loads(json_string
))
252 def CopyFrom(self
, source
):
253 """Update the content of the bundle by copying values from the given
257 source: The dictionary whose values must be copied to the bundle."""
258 for key
, value
in source
.items():
259 if key
== ARCHIVES_KEY
:
262 new_archive
= Archive(a
['host_os'])
263 new_archive
.CopyFrom(a
)
264 archives
.append(new_archive
)
265 self
[ARCHIVES_KEY
] = archives
269 def Validate(self
, add_missing_info
=False, error_on_unknown_keys
=False):
270 """Validate the content of the bundle. Raise an Error if an invalid or
271 missing field is found.
274 error_on_unknown_keys: If True, raise an Error when unknown keys are
277 # Check required fields.
278 if not self
.get(NAME_KEY
):
279 raise Error('Bundle has no name')
280 if self
.get(REVISION_KEY
) == None:
281 raise Error('Bundle "%s" is missing a revision number' % self
[NAME_KEY
])
282 if self
.get(VERSION_KEY
) == None:
283 raise Error('Bundle "%s" is missing a version number' % self
[NAME_KEY
])
284 if not self
.get('description'):
285 raise Error('Bundle "%s" is missing a description' % self
[NAME_KEY
])
286 if not self
.get('stability'):
287 raise Error('Bundle "%s" is missing stability info' % self
[NAME_KEY
])
288 if self
.get('recommended') == None:
289 raise Error('Bundle "%s" is missing the recommended field' %
291 # Check specific values
292 if self
['stability'] not in STABILITY_LITERALS
:
293 raise Error('Bundle "%s" has invalid stability field: "%s"' %
294 (self
[NAME_KEY
], self
['stability']))
295 if self
['recommended'] not in YES_NO_LITERALS
:
297 'Bundle "%s" has invalid recommended field: "%s"' %
298 (self
[NAME_KEY
], self
['recommended']))
299 # Verify that all key names are valid.
300 if error_on_unknown_keys
:
302 if key
not in VALID_BUNDLES_KEYS
:
303 raise Error('Bundle "%s" has invalid attribute "%s"' %
304 (self
[NAME_KEY
], key
))
305 # Validate the archives
306 for archive
in self
[ARCHIVES_KEY
]:
307 if add_missing_info
and 'size' not in archive
:
308 archive
.UpdateVitals(self
[REVISION_KEY
])
309 archive
.Validate(error_on_unknown_keys
)
311 def GetArchive(self
, host_os_name
):
312 """Retrieve the archive for the given host os.
315 host_os_name: name of host os whose archive must be retrieved.
317 An Archive instance or None if it doesn't exist."""
318 for archive
in self
[ARCHIVES_KEY
]:
319 if archive
.host_os
== host_os_name
or archive
.host_os
== 'all':
323 def GetHostOSArchive(self
):
324 """Retrieve the archive for the current host os."""
325 return self
.GetArchive(GetHostOS())
327 def GetHostOSArchives(self
):
328 """Retrieve all archives for the current host os, or marked all.
330 return [archive
for archive
in self
.GetArchives()
331 if archive
.host_os
in (GetHostOS(), 'all')]
333 def GetArchives(self
):
334 """Returns all the archives in this bundle"""
335 return self
[ARCHIVES_KEY
]
337 def AddArchive(self
, archive
):
338 """Add an archive to this bundle."""
339 self
[ARCHIVES_KEY
].append(archive
)
341 def RemoveAllArchives(self
):
342 """Remove all archives from this Bundle."""
343 del self
[ARCHIVES_KEY
][:]
345 def RemoveAllArchivesForHostOS(self
, host_os_name
):
346 """Remove an archive from this Bundle."""
347 if host_os_name
== 'all':
348 del self
[ARCHIVES_KEY
][:]
350 for i
, archive
in enumerate(self
[ARCHIVES_KEY
]):
351 if archive
.host_os
== host_os_name
:
352 del self
[ARCHIVES_KEY
][i
]
354 def __getattr__(self
, name
):
355 """Retrieve values from this dict using attributes.
357 This allows for foo.bar instead of foo['bar'].
360 name: the name of the key, 'bar' in the example above.
362 The value associated with that key."""
364 raise AttributeError(name
)
365 return self
.__getitem
__(name
)
367 def __setattr__(self
, name
, value
):
368 """Set values in this dict using attributes.
370 This allows for foo.bar instead of foo['bar'].
373 name: The name of the key, 'bar' in the example above.
374 value: The value to associate with that key."""
375 self
.__setitem
__(name
, value
)
377 def __eq__(self
, bundle
):
378 """Test if two bundles are equal.
380 Normally the default comparison for two dicts is fine, but in this case we
381 don't care about the list order of the archives.
384 bundle: The other bundle to compare against.
386 True if the bundles are equal."""
387 if not isinstance(bundle
, Bundle
):
389 if len(self
.keys()) != len(bundle
.keys()):
391 for key
in self
.keys():
392 if key
not in bundle
:
394 # special comparison for ARCHIVE_KEY because we don't care about the list
396 if key
== ARCHIVES_KEY
:
397 if len(self
[key
]) != len(bundle
[key
]):
399 for archive
in self
[key
]:
400 if archive
!= bundle
.GetArchive(archive
.host_os
):
402 elif self
[key
] != bundle
[key
]:
406 def __ne__(self
, bundle
):
407 """Test if two bundles are unequal.
409 See __eq__ for more info."""
410 return not self
.__eq
__(bundle
)
413 class SDKManifest(object):
414 """This class contains utilities for manipulation an SDK manifest string
416 For ease of unit-testing, this class should not contain any file I/O.
420 """Create a new SDKManifest object with default contents"""
421 self
._manifest
_data
= {
422 "manifest_version": MANIFEST_VERSION
,
426 def Validate(self
, add_missing_info
=False):
427 """Validate the Manifest file and raises an exception for problems"""
428 # Validate the manifest top level
429 if self
._manifest
_data
["manifest_version"] > MANIFEST_VERSION
:
430 raise Error("Manifest version too high: %s" %
431 self
._manifest
_data
["manifest_version"])
432 # Verify that all key names are valid.
433 for key
in self
._manifest
_data
:
434 if key
not in VALID_MANIFEST_KEYS
:
435 raise Error('Manifest has invalid attribute "%s"' % key
)
436 # Validate each bundle
437 for bundle
in self
._manifest
_data
[BUNDLES_KEY
]:
438 bundle
.Validate(add_missing_info
)
440 def GetBundle(self
, name
):
441 """Get a bundle from the array of bundles.
444 name: the name of the bundle to return.
446 The first bundle with the given name, or None if it is not found."""
447 if not BUNDLES_KEY
in self
._manifest
_data
:
449 bundles
= [bundle
for bundle
in self
._manifest
_data
[BUNDLES_KEY
]
450 if bundle
[NAME_KEY
] == name
]
452 sys
.stderr
.write("WARNING: More than one bundle with name"
453 "'%s' exists.\n" % name
)
454 return bundles
[0] if len(bundles
) > 0 else None
456 def GetBundles(self
):
457 """Return all the bundles in the manifest."""
458 return self
._manifest
_data
[BUNDLES_KEY
]
460 def SetBundle(self
, new_bundle
):
461 """Add or replace a bundle in the manifest.
463 Note: If a bundle in the manifest already exists with this name, it will be
464 overwritten with a copy of this bundle, at the same index as the original.
469 name
= new_bundle
[NAME_KEY
]
470 bundles
= self
.GetBundles()
471 new_bundle_copy
= copy
.deepcopy(new_bundle
)
472 for i
, bundle
in enumerate(bundles
):
473 if bundle
[NAME_KEY
] == name
:
474 bundles
[i
] = new_bundle_copy
476 # Bundle not already in list, append it.
477 bundles
.append(new_bundle_copy
)
479 def RemoveBundle(self
, name
):
480 """Remove a bundle by name.
483 name: the name of the bundle to remove.
485 True if the bundle was removed, False if there is no bundle with that
488 if not BUNDLES_KEY
in self
._manifest
_data
:
490 bundles
= self
._manifest
_data
[BUNDLES_KEY
]
491 for i
, bundle
in enumerate(bundles
):
492 if bundle
[NAME_KEY
] == name
:
497 def BundleNeedsUpdate(self
, bundle
):
498 """Decides if a bundle needs to be updated.
500 A bundle needs to be updated if it is not installed (doesn't exist in this
501 manifest file) or if its revision is later than the revision in this file.
504 bundle: The Bundle to test.
506 True if Bundle needs to be updated.
508 if NAME_KEY
not in bundle
:
509 raise KeyError("Bundle must have a 'name' key.")
510 local_bundle
= self
.GetBundle(bundle
[NAME_KEY
])
511 return (local_bundle
== None) or (
512 (local_bundle
[VERSION_KEY
], local_bundle
[REVISION_KEY
]) <
513 (bundle
[VERSION_KEY
], bundle
[REVISION_KEY
]))
515 def MergeBundle(self
, bundle
, allow_existing
=True):
516 """Merge a Bundle into this manifest.
518 The new bundle is added if not present, or merged into the existing bundle.
521 bundle: The bundle to merge.
523 if NAME_KEY
not in bundle
:
524 raise KeyError("Bundle must have a 'name' key.")
525 local_bundle
= self
.GetBundle(bundle
.name
)
527 self
.SetBundle(bundle
)
529 if not allow_existing
:
530 raise Error('cannot merge manifest bundle \'%s\', it already exists'
532 local_bundle
.MergeWithBundle(bundle
)
534 def MergeManifest(self
, manifest
):
535 '''Merge another manifest into this manifest, disallowing overriding.
538 manifest: The manifest to merge.
540 for bundle
in manifest
.GetBundles():
541 self
.MergeBundle(bundle
, allow_existing
=False)
543 def FilterBundles(self
, predicate
):
544 """Filter the list of bundles by |predicate|.
546 For all bundles in this manifest, if predicate(bundle) is False, the bundle
547 is removed from the manifest.
550 predicate: a function that take a bundle and returns whether True to keep
551 it or False to remove it.
553 self
._manifest
_data
[BUNDLES_KEY
] = filter(predicate
, self
.GetBundles())
555 def LoadDataFromString(self
, json_string
, add_missing_info
=False):
556 """Load a JSON manifest string. Raises an exception if json_string
557 is not well-formed JSON.
560 json_string: a JSON-formatted string containing the previous manifest
561 all_hosts: True indicates that we should load bundles for all hosts.
562 False (default) says to only load bundles for the current host"""
563 new_manifest
= json
.loads(json_string
)
564 for key
, value
in new_manifest
.items():
565 if key
== BUNDLES_KEY
:
566 # Remap each bundle in |value| to a Bundle instance
569 new_bundle
= Bundle(b
[NAME_KEY
])
570 new_bundle
.CopyFrom(b
)
571 bundles
.append(new_bundle
)
572 self
._manifest
_data
[key
] = bundles
574 self
._manifest
_data
[key
] = value
575 self
.Validate(add_missing_info
)
578 return self
.GetDataAsString()
580 def __eq__(self
, other
):
581 # Access to protected member _manifest_data of a client class
582 # pylint: disable=W0212
583 if (self
._manifest
_data
['manifest_version'] !=
584 other
._manifest
_data
['manifest_version']):
587 self_bundle_names
= set(b
.name
for b
in self
.GetBundles())
588 other_bundle_names
= set(b
.name
for b
in other
.GetBundles())
589 if self_bundle_names
!= other_bundle_names
:
592 for bundle_name
in self_bundle_names
:
593 if self
.GetBundle(bundle_name
) != other
.GetBundle(bundle_name
):
598 def __ne__(self
, other
):
599 return not (self
== other
)
601 def GetDataAsString(self
):
602 """Returns the current JSON manifest object, pretty-printed"""
603 return DictToJSON(self
._manifest
_data
)