ota_from_target_files.py revision 962069ce59c85949d147874df2728a5ffd9193be
1#!/usr/bin/env python 2# 3# Copyright (C) 2008 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17""" 18Given a target-files zipfile, produces an OTA package that installs 19that build. An incremental OTA is produced if -i is given, otherwise 20a full OTA is produced. 21 22Usage: ota_from_target_files [flags] input_target_files output_ota_package 23 24 -b (--board_config) <file> 25 Specifies a BoardConfig.mk file containing image max sizes 26 against which the generated image files are checked. 27 28 -k (--package_key) <key> 29 Key to use to sign the package (default is 30 "build/target/product/security/testkey"). 31 32 -i (--incremental_from) <file> 33 Generate an incremental OTA using the given target-files zip as 34 the starting build. 35 36 -w (--wipe_user_data) 37 Generate an OTA package that will wipe the user data partition 38 when installed. 39 40 -n (--no_prereq) 41 Omit the timestamp prereq check normally included at the top of 42 the build scripts (used for developer OTA packages which 43 legitimately need to go back and forth). 44 45""" 46 47import sys 48 49if sys.hexversion < 0x02040000: 50 print >> sys.stderr, "Python 2.4 or newer is required." 51 sys.exit(1) 52 53import copy 54import os 55import re 56import sha 57import subprocess 58import tempfile 59import time 60import zipfile 61 62import common 63 64OPTIONS = common.OPTIONS 65OPTIONS.package_key = "build/target/product/security/testkey" 66OPTIONS.incremental_source = None 67OPTIONS.require_verbatim = set() 68OPTIONS.prohibit_verbatim = set(("system/build.prop",)) 69OPTIONS.patch_threshold = 0.95 70OPTIONS.wipe_user_data = False 71OPTIONS.omit_prereq = False 72 73def MostPopularKey(d, default): 74 """Given a dict, return the key corresponding to the largest 75 value. Returns 'default' if the dict is empty.""" 76 x = [(v, k) for (k, v) in d.iteritems()] 77 if not x: return default 78 x.sort() 79 return x[-1][1] 80 81 82def IsSymlink(info): 83 """Return true if the zipfile.ZipInfo object passed in represents a 84 symlink.""" 85 return (info.external_attr >> 16) == 0120777 86 87 88 89class Item: 90 """Items represent the metadata (user, group, mode) of files and 91 directories in the system image.""" 92 ITEMS = {} 93 def __init__(self, name, dir=False): 94 self.name = name 95 self.uid = None 96 self.gid = None 97 self.mode = None 98 self.dir = dir 99 100 if name: 101 self.parent = Item.Get(os.path.dirname(name), dir=True) 102 self.parent.children.append(self) 103 else: 104 self.parent = None 105 if dir: 106 self.children = [] 107 108 def Dump(self, indent=0): 109 if self.uid is not None: 110 print "%s%s %d %d %o" % (" "*indent, self.name, self.uid, self.gid, self.mode) 111 else: 112 print "%s%s %s %s %s" % (" "*indent, self.name, self.uid, self.gid, self.mode) 113 if self.dir: 114 print "%s%s" % (" "*indent, self.descendants) 115 print "%s%s" % (" "*indent, self.best_subtree) 116 for i in self.children: 117 i.Dump(indent=indent+1) 118 119 @classmethod 120 def Get(cls, name, dir=False): 121 if name not in cls.ITEMS: 122 cls.ITEMS[name] = Item(name, dir=dir) 123 return cls.ITEMS[name] 124 125 @classmethod 126 def GetMetadata(cls): 127 """Run the external 'fs_config' program to determine the desired 128 uid, gid, and mode for every Item object.""" 129 p = common.Run(["fs_config"], stdin=subprocess.PIPE, 130 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 131 suffix = { False: "", True: "/" } 132 input = "".join(["%s%s\n" % (i.name, suffix[i.dir]) 133 for i in cls.ITEMS.itervalues() if i.name]) 134 output, error = p.communicate(input) 135 assert not error 136 137 for line in output.split("\n"): 138 if not line: continue 139 name, uid, gid, mode = line.split() 140 i = cls.ITEMS[name] 141 i.uid = int(uid) 142 i.gid = int(gid) 143 i.mode = int(mode, 8) 144 if i.dir: 145 i.children.sort(key=lambda i: i.name) 146 147 def CountChildMetadata(self): 148 """Count up the (uid, gid, mode) tuples for all children and 149 determine the best strategy for using set_perm_recursive and 150 set_perm to correctly chown/chmod all the files to their desired 151 values. Recursively calls itself for all descendants. 152 153 Returns a dict of {(uid, gid, dmode, fmode): count} counting up 154 all descendants of this node. (dmode or fmode may be None.) Also 155 sets the best_subtree of each directory Item to the (uid, gid, 156 dmode, fmode) tuple that will match the most descendants of that 157 Item. 158 """ 159 160 assert self.dir 161 d = self.descendants = {(self.uid, self.gid, self.mode, None): 1} 162 for i in self.children: 163 if i.dir: 164 for k, v in i.CountChildMetadata().iteritems(): 165 d[k] = d.get(k, 0) + v 166 else: 167 k = (i.uid, i.gid, None, i.mode) 168 d[k] = d.get(k, 0) + 1 169 170 # Find the (uid, gid, dmode, fmode) tuple that matches the most 171 # descendants. 172 173 # First, find the (uid, gid) pair that matches the most 174 # descendants. 175 ug = {} 176 for (uid, gid, _, _), count in d.iteritems(): 177 ug[(uid, gid)] = ug.get((uid, gid), 0) + count 178 ug = MostPopularKey(ug, (0, 0)) 179 180 # Now find the dmode and fmode that match the most descendants 181 # with that (uid, gid), and choose those. 182 best_dmode = (0, 0755) 183 best_fmode = (0, 0644) 184 for k, count in d.iteritems(): 185 if k[:2] != ug: continue 186 if k[2] is not None and count >= best_dmode[0]: best_dmode = (count, k[2]) 187 if k[3] is not None and count >= best_fmode[0]: best_fmode = (count, k[3]) 188 self.best_subtree = ug + (best_dmode[1], best_fmode[1]) 189 190 return d 191 192 def SetPermissions(self, script, renamer=lambda x: x): 193 """Append set_perm/set_perm_recursive commands to 'script' to 194 set all permissions, users, and groups for the tree of files 195 rooted at 'self'. 'renamer' turns the filenames stored in the 196 tree of Items into the strings used in the script.""" 197 198 self.CountChildMetadata() 199 200 def recurse(item, current): 201 # current is the (uid, gid, dmode, fmode) tuple that the current 202 # item (and all its children) have already been set to. We only 203 # need to issue set_perm/set_perm_recursive commands if we're 204 # supposed to be something different. 205 if item.dir: 206 if current != item.best_subtree: 207 script.append("set_perm_recursive %d %d 0%o 0%o %s" % 208 (item.best_subtree + (renamer(item.name),))) 209 current = item.best_subtree 210 211 if item.uid != current[0] or item.gid != current[1] or \ 212 item.mode != current[2]: 213 script.append("set_perm %d %d 0%o %s" % 214 (item.uid, item.gid, item.mode, renamer(item.name))) 215 216 for i in item.children: 217 recurse(i, current) 218 else: 219 if item.uid != current[0] or item.gid != current[1] or \ 220 item.mode != current[3]: 221 script.append("set_perm %d %d 0%o %s" % 222 (item.uid, item.gid, item.mode, renamer(item.name))) 223 224 recurse(self, (-1, -1, -1, -1)) 225 226 227def CopySystemFiles(input_zip, output_zip=None, 228 substitute=None): 229 """Copies files underneath system/ in the input zip to the output 230 zip. Populates the Item class with their metadata, and returns a 231 list of symlinks. output_zip may be None, in which case the copy is 232 skipped (but the other side effects still happen). substitute is an 233 optional dict of {output filename: contents} to be output instead of 234 certain input files. 235 """ 236 237 symlinks = [] 238 239 for info in input_zip.infolist(): 240 if info.filename.startswith("SYSTEM/"): 241 basefilename = info.filename[7:] 242 if IsSymlink(info): 243 symlinks.append((input_zip.read(info.filename), 244 "SYSTEM:" + basefilename)) 245 else: 246 info2 = copy.copy(info) 247 fn = info2.filename = "system/" + basefilename 248 if substitute and fn in substitute and substitute[fn] is None: 249 continue 250 if output_zip is not None: 251 if substitute and fn in substitute: 252 data = substitute[fn] 253 else: 254 data = input_zip.read(info.filename) 255 output_zip.writestr(info2, data) 256 if fn.endswith("/"): 257 Item.Get(fn[:-1], dir=True) 258 else: 259 Item.Get(fn, dir=False) 260 261 symlinks.sort() 262 return symlinks 263 264 265def AddScript(script, output_zip): 266 now = time.localtime() 267 i = zipfile.ZipInfo("META-INF/com/google/android/update-script", 268 (now.tm_year, now.tm_mon, now.tm_mday, 269 now.tm_hour, now.tm_min, now.tm_sec)) 270 output_zip.writestr(i, "\n".join(script) + "\n") 271 272 273def SignOutput(temp_zip_name, output_zip_name): 274 key_passwords = common.GetKeyPasswords([OPTIONS.package_key]) 275 pw = key_passwords[OPTIONS.package_key] 276 277 common.SignFile(temp_zip_name, output_zip_name, OPTIONS.package_key, pw) 278 279 280def SubstituteRoot(s): 281 if s == "system": return "SYSTEM:" 282 assert s.startswith("system/") 283 return "SYSTEM:" + s[7:] 284 285def FixPermissions(script): 286 Item.GetMetadata() 287 root = Item.Get("system") 288 root.SetPermissions(script, renamer=SubstituteRoot) 289 290def DeleteFiles(script, to_delete): 291 line = [] 292 t = 0 293 for i in to_delete: 294 line.append(i) 295 t += len(i) + 1 296 if t > 80: 297 script.append("delete " + " ".join(line)) 298 line = [] 299 t = 0 300 if line: 301 script.append("delete " + " ".join(line)) 302 303def AppendAssertions(script, input_zip): 304 script.append('assert compatible_with("0.2") == "true"') 305 306 device = GetBuildProp("ro.product.device", input_zip) 307 script.append('assert getprop("ro.product.device") == "%s" || ' 308 'getprop("ro.build.product") == "%s"' % (device, device)) 309 310 info = input_zip.read("OTA/android-info.txt") 311 m = re.search(r"require\s+version-bootloader\s*=\s*(\S+)", info) 312 if not m: 313 raise ExternalError("failed to find required bootloaders in " 314 "android-info.txt") 315 bootloaders = m.group(1).split("|") 316 script.append("assert " + 317 " || ".join(['getprop("ro.bootloader") == "%s"' % (b,) 318 for b in bootloaders])) 319 320 321def IncludeBinary(name, input_zip, output_zip): 322 try: 323 data = input_zip.read(os.path.join("OTA/bin", name)) 324 output_zip.writestr(name, data) 325 except IOError: 326 raise ExternalError('unable to include device binary "%s"' % (name,)) 327 328 329def WriteFullOTAPackage(input_zip, output_zip): 330 script = [] 331 332 if not OPTIONS.omit_prereq: 333 ts = GetBuildProp("ro.build.date.utc", input_zip) 334 script.append("run_program PACKAGE:check_prereq %s" % (ts,)) 335 IncludeBinary("check_prereq", input_zip, output_zip) 336 337 AppendAssertions(script, input_zip) 338 339 script.append("format BOOT:") 340 script.append("show_progress 0.1 0") 341 342 output_zip.writestr("radio.img", input_zip.read("RADIO/image")) 343 script.append("write_radio_image PACKAGE:radio.img") 344 script.append("show_progress 0.5 0") 345 346 if OPTIONS.wipe_user_data: 347 script.append("format DATA:") 348 349 script.append("format SYSTEM:") 350 script.append("copy_dir PACKAGE:system SYSTEM:") 351 352 symlinks = CopySystemFiles(input_zip, output_zip) 353 script.extend(["symlink %s %s" % s for s in symlinks]) 354 355 common.BuildAndAddBootableImage(os.path.join(OPTIONS.input_tmp, "RECOVERY"), 356 "system/recovery.img", output_zip) 357 Item.Get("system/recovery.img", dir=False) 358 359 FixPermissions(script) 360 361 common.AddBoot(output_zip) 362 script.append("show_progress 0.2 0") 363 script.append("write_raw_image PACKAGE:boot.img BOOT:") 364 script.append("show_progress 0.2 10") 365 366 AddScript(script, output_zip) 367 368 369class File(object): 370 def __init__(self, name, data): 371 self.name = name 372 self.data = data 373 self.size = len(data) 374 self.sha1 = sha.sha(data).hexdigest() 375 376 def WriteToTemp(self): 377 t = tempfile.NamedTemporaryFile() 378 t.write(self.data) 379 t.flush() 380 return t 381 382 def AddToZip(self, z): 383 z.writestr(self.name, self.data) 384 385 386def LoadSystemFiles(z): 387 """Load all the files from SYSTEM/... in a given target-files 388 ZipFile, and return a dict of {filename: File object}.""" 389 out = {} 390 for info in z.infolist(): 391 if info.filename.startswith("SYSTEM/") and not IsSymlink(info): 392 fn = "system/" + info.filename[7:] 393 data = z.read(info.filename) 394 out[fn] = File(fn, data) 395 return out 396 397 398def Difference(tf, sf): 399 """Return the patch (as a string of data) needed to turn sf into tf.""" 400 401 ttemp = tf.WriteToTemp() 402 stemp = sf.WriteToTemp() 403 404 ext = os.path.splitext(tf.name)[1] 405 406 try: 407 ptemp = tempfile.NamedTemporaryFile() 408 p = common.Run(["bsdiff", stemp.name, ttemp.name, ptemp.name]) 409 _, err = p.communicate() 410 if err: 411 raise ExternalError("failure running bsdiff:\n%s\n" % (err,)) 412 diff = ptemp.read() 413 ptemp.close() 414 finally: 415 stemp.close() 416 ttemp.close() 417 418 return diff 419 420 421def GetBuildProp(property, z): 422 """Return the fingerprint of the build of a given target-files 423 ZipFile object.""" 424 bp = z.read("SYSTEM/build.prop") 425 if not property: 426 return bp 427 m = re.search(re.escape(property) + r"=(.*)\n", bp) 428 if not m: 429 raise ExternalException("couldn't find %s in build.prop" % (property,)) 430 return m.group(1).strip() 431 432 433def WriteIncrementalOTAPackage(target_zip, source_zip, output_zip): 434 script = [] 435 436 print "Loading target..." 437 target_data = LoadSystemFiles(target_zip) 438 print "Loading source..." 439 source_data = LoadSystemFiles(source_zip) 440 441 verbatim_targets = [] 442 patch_list = [] 443 largest_source_size = 0 444 for fn in sorted(target_data.keys()): 445 tf = target_data[fn] 446 sf = source_data.get(fn, None) 447 448 if sf is None or fn in OPTIONS.require_verbatim: 449 # This file should be included verbatim 450 if fn in OPTIONS.prohibit_verbatim: 451 raise ExternalError("\"%s\" must be sent verbatim" % (fn,)) 452 print "send", fn, "verbatim" 453 tf.AddToZip(output_zip) 454 verbatim_targets.append((fn, tf.size)) 455 elif tf.sha1 != sf.sha1: 456 # File is different; consider sending as a patch 457 d = Difference(tf, sf) 458 print fn, tf.size, len(d), (float(len(d)) / tf.size) 459 if len(d) > tf.size * OPTIONS.patch_threshold: 460 # patch is almost as big as the file; don't bother patching 461 tf.AddToZip(output_zip) 462 verbatim_targets.append((fn, tf.size)) 463 else: 464 output_zip.writestr("patch/" + fn + ".p", d) 465 patch_list.append((fn, tf, sf, tf.size)) 466 largest_source_size = max(largest_source_size, sf.size) 467 else: 468 # Target file identical to source. 469 pass 470 471 total_verbatim_size = sum([i[1] for i in verbatim_targets]) 472 total_patched_size = sum([i[3] for i in patch_list]) 473 474 source_fp = GetBuildProp("ro.build.fingerprint", source_zip) 475 target_fp = GetBuildProp("ro.build.fingerprint", target_zip) 476 477 script.append(('assert file_contains("SYSTEM:build.prop", ' 478 '"ro.build.fingerprint=%s") == "true" || ' 479 'file_contains("SYSTEM:build.prop", ' 480 '"ro.build.fingerprint=%s") == "true"') % 481 (source_fp, target_fp)) 482 483 source_boot = common.BuildBootableImage( 484 os.path.join(OPTIONS.source_tmp, "BOOT")) 485 target_boot = common.BuildBootableImage( 486 os.path.join(OPTIONS.target_tmp, "BOOT")) 487 updating_boot = (source_boot != target_boot) 488 489 source_recovery = common.BuildBootableImage( 490 os.path.join(OPTIONS.source_tmp, "RECOVERY")) 491 target_recovery = common.BuildBootableImage( 492 os.path.join(OPTIONS.target_tmp, "RECOVERY")) 493 updating_recovery = (source_recovery != target_recovery) 494 495 source_radio = source_zip.read("RADIO/image") 496 target_radio = target_zip.read("RADIO/image") 497 updating_radio = (source_radio != target_radio) 498 499 # The last 0.1 is reserved for creating symlinks, fixing 500 # permissions, and writing the boot image (if necessary). 501 progress_bar_total = 1.0 502 if updating_boot: 503 progress_bar_total -= 0.1 504 if updating_radio: 505 progress_bar_total -= 0.3 506 507 AppendAssertions(script, target_zip) 508 509 pb_verify = progress_bar_total * 0.3 * \ 510 (total_patched_size / 511 float(total_patched_size+total_verbatim_size)) 512 513 for i, (fn, tf, sf, size) in enumerate(patch_list): 514 if i % 5 == 0: 515 next_sizes = sum([i[3] for i in patch_list[i:i+5]]) 516 script.append("show_progress %f 1" % 517 (next_sizes * pb_verify / total_patched_size,)) 518 script.append("run_program PACKAGE:applypatch -c /%s %s %s" % 519 (fn, tf.sha1, sf.sha1)) 520 521 if patch_list: 522 script.append("run_program PACKAGE:applypatch -s %d" % 523 (largest_source_size,)) 524 script.append("copy_dir PACKAGE:patch CACHE:../tmp/patchtmp") 525 IncludeBinary("applypatch", target_zip, output_zip) 526 527 script.append("\n# ---- start making changes here\n") 528 529 if OPTIONS.wipe_user_data: 530 script.append("format DATA:") 531 532 DeleteFiles(script, [SubstituteRoot(i[0]) for i in verbatim_targets]) 533 534 if updating_boot: 535 script.append("format BOOT:") 536 output_zip.writestr("boot.img", target_boot) 537 print "boot image changed; including." 538 else: 539 print "boot image unchanged; skipping." 540 541 if updating_recovery: 542 output_zip.writestr("system/recovery.img", target_recovery) 543 print "recovery image changed; including." 544 else: 545 print "recovery image unchanged; skipping." 546 547 if updating_radio: 548 script.append("show_progress 0.3 10") 549 script.append("write_radio_image PACKAGE:radio.img") 550 output_zip.writestr("radio.img", target_radio) 551 print "radio image changed; including." 552 else: 553 print "radio image unchanged; skipping." 554 555 pb_apply = progress_bar_total * 0.7 * \ 556 (total_patched_size / 557 float(total_patched_size+total_verbatim_size)) 558 for i, (fn, tf, sf, size) in enumerate(patch_list): 559 if i % 5 == 0: 560 next_sizes = sum([i[3] for i in patch_list[i:i+5]]) 561 script.append("show_progress %f 1" % 562 (next_sizes * pb_apply / total_patched_size,)) 563 script.append(("run_program PACKAGE:applypatch " 564 "/%s %s %d %s:/tmp/patchtmp/%s.p") % 565 (fn, tf.sha1, tf.size, sf.sha1, fn)) 566 567 target_symlinks = CopySystemFiles(target_zip, None) 568 569 target_symlinks_d = dict([(i[1], i[0]) for i in target_symlinks]) 570 temp_script = [] 571 FixPermissions(temp_script) 572 573 # Note that this call will mess up the tree of Items, so make sure 574 # we're done with it. 575 source_symlinks = CopySystemFiles(source_zip, None) 576 source_symlinks_d = dict([(i[1], i[0]) for i in source_symlinks]) 577 578 # Delete all the symlinks in source that aren't in target. This 579 # needs to happen before verbatim files are unpacked, in case a 580 # symlink in the source is replaced by a real file in the target. 581 to_delete = [] 582 for dest, link in source_symlinks: 583 if link not in target_symlinks_d: 584 to_delete.append(link) 585 DeleteFiles(script, to_delete) 586 587 if verbatim_targets: 588 pb_verbatim = progress_bar_total * \ 589 (total_verbatim_size / 590 float(total_patched_size+total_verbatim_size)) 591 script.append("show_progress %f 5" % (pb_verbatim,)) 592 script.append("copy_dir PACKAGE:system SYSTEM:") 593 594 # Create all the symlinks that don't already exist, or point to 595 # somewhere different than what we want. Delete each symlink before 596 # creating it, since the 'symlink' command won't overwrite. 597 to_create = [] 598 for dest, link in target_symlinks: 599 if link in source_symlinks_d: 600 if dest != source_symlinks_d[link]: 601 to_create.append((dest, link)) 602 else: 603 to_create.append((dest, link)) 604 DeleteFiles(script, [i[1] for i in to_create]) 605 script.extend(["symlink %s %s" % s for s in to_create]) 606 607 # Now that the symlinks are created, we can set all the 608 # permissions. 609 script.extend(temp_script) 610 611 if updating_boot: 612 script.append("show_progress 0.1 5") 613 script.append("write_raw_image PACKAGE:boot.img BOOT:") 614 615 AddScript(script, output_zip) 616 617 618def main(argv): 619 620 def option_handler(o, a): 621 if o in ("-b", "--board_config"): 622 common.LoadBoardConfig(a) 623 elif o in ("-k", "--package_key"): 624 OPTIONS.package_key = a 625 elif o in ("-i", "--incremental_from"): 626 OPTIONS.incremental_source = a 627 elif o in ("-w", "--wipe_user_data"): 628 OPTIONS.wipe_user_data = True 629 elif o in ("-n", "--no_prereq"): 630 OPTIONS.omit_prereq = True 631 else: 632 return False 633 return True 634 635 args = common.ParseOptions(argv, __doc__, 636 extra_opts="b:k:i:d:wn", 637 extra_long_opts=["board_config=", 638 "package_key=", 639 "incremental_from=", 640 "wipe_user_data", 641 "no_prereq"], 642 extra_option_handler=option_handler) 643 644 if len(args) != 2: 645 common.Usage(__doc__) 646 sys.exit(1) 647 648 if not OPTIONS.max_image_size: 649 print 650 print " WARNING: No board config specified; will not check image" 651 print " sizes against limits. Use -b to make sure the generated" 652 print " images don't exceed partition sizes." 653 print 654 655 print "unzipping target target-files..." 656 OPTIONS.input_tmp = common.UnzipTemp(args[0]) 657 OPTIONS.target_tmp = OPTIONS.input_tmp 658 input_zip = zipfile.ZipFile(args[0], "r") 659 if OPTIONS.package_key: 660 temp_zip_file = tempfile.NamedTemporaryFile() 661 output_zip = zipfile.ZipFile(temp_zip_file, "w", 662 compression=zipfile.ZIP_DEFLATED) 663 else: 664 output_zip = zipfile.ZipFile(args[1], "w", 665 compression=zipfile.ZIP_DEFLATED) 666 667 if OPTIONS.incremental_source is None: 668 WriteFullOTAPackage(input_zip, output_zip) 669 else: 670 print "unzipping source target-files..." 671 OPTIONS.source_tmp = common.UnzipTemp(OPTIONS.incremental_source) 672 source_zip = zipfile.ZipFile(OPTIONS.incremental_source, "r") 673 WriteIncrementalOTAPackage(input_zip, source_zip, output_zip) 674 675 output_zip.close() 676 if OPTIONS.package_key: 677 SignOutput(temp_zip_file.name, args[1]) 678 temp_zip_file.close() 679 680 common.Cleanup() 681 682 print "done." 683 684 685if __name__ == '__main__': 686 try: 687 main(sys.argv[1:]) 688 except common.ExternalError, e: 689 print 690 print " ERROR: %s" % (e,) 691 print 692 sys.exit(1) 693