generate-waterfall-reports.py revision 63824523d7f5b3afa6a8e939db3503cf867e7c40
1#!/usr/bin/env python2 2"""Generate summary report for ChromeOS toolchain waterfalls.""" 3 4# Desired future features (to be added): 5# - arguments to allow generating only the main waterfall report, 6# or only the rotating builder reports, or only the failures 7# report; or the waterfall reports without the failures report. 8# - Better way of figuring out which dates/builds to generate 9# reports for: probably an argument specifying a date or a date 10# range, then use something like the new buildbot utils to 11# query the build logs to find the right build numbers for the 12# builders for the specified dates. 13# - Store/get the json/data files in mobiletc-prebuild's x20 area. 14# - Update data in json file to reflect, for each testsuite, which 15# tests are not expected to run on which boards; update this 16# script to use that data appropriately. 17# - Make sure user's prodaccess is up-to-date before trying to use 18# this script. 19# - Add some nice formatting/highlighting to reports. 20 21from __future__ import print_function 22 23import getpass 24import json 25import os 26import shutil 27import sys 28import time 29 30from cros_utils import command_executer 31 32# All the test suites whose data we might want for the reports. 33TESTS = ( 34 ('bvt-inline', 'HWTest'), 35 ('bvt-cq', 'HWTest'), 36 ('toolchain-tests', 'HWTest'), 37 ('security', 'HWTest'), 38 ('kernel_daily_regression', 'HWTest'), 39 ('kernel_daily_benchmarks', 'HWTest'),) 40 41# The main waterfall builders, IN THE ORDER IN WHICH WE WANT THEM 42# LISTED IN THE REPORT. 43WATERFALL_BUILDERS = [ 44 'amd64-gcc-toolchain', 'arm-gcc-toolchain', 'arm64-gcc-toolchain', 45 'x86-gcc-toolchain', 'amd64-llvm-toolchain', 'arm-llvm-toolchain', 46 'arm64-llvm-toolchain', 'x86-llvm-toolchain', 'amd64-llvm-next-toolchain', 47 'arm-llvm-next-toolchain', 'arm64-llvm-next-toolchain', 48 'x86-llvm-next-toolchain' 49] 50 51DATA_DIR = '/google/data/rw/users/mo/mobiletc-prebuild/waterfall-report-data/' 52ARCHIVE_DIR = '/google/data/rw/users/mo/mobiletc-prebuild/waterfall-reports/' 53DOWNLOAD_DIR = '/tmp/waterfall-logs' 54MAX_SAVE_RECORDS = 7 55BUILD_DATA_FILE = '%s/build-data.txt' % DATA_DIR 56ROTATING_BUILDERS = ['gcc_toolchain', 'llvm_toolchain'] 57 58# For int-to-string date conversion. Note, the index of the month in this 59# list needs to correspond to the month's integer value. i.e. 'Sep' must 60# be as MONTHS[9]. 61MONTHS = [ 62 '', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 63 'Nov', 'Dec' 64] 65 66 67def format_date(int_date): 68 """Convert an integer date to a string date. YYYYMMDD -> YYYY-MMM-DD""" 69 70 if int_date == 0: 71 return 'today' 72 73 tmp_date = int_date 74 day = tmp_date % 100 75 tmp_date = tmp_date / 100 76 month = tmp_date % 100 77 year = tmp_date / 100 78 79 month_str = MONTHS[month] 80 date_str = '%d-%s-%d' % (year, month_str, day) 81 return date_str 82 83 84def EmailReport(report_file, report_type, date): 85 subject = '%s Waterfall Summary report, %s' % (report_type, date) 86 email_to = getpass.getuser() 87 sendgmr_path = '/google/data/ro/projects/gws-sre/sendgmr' 88 command = ('%s --to=%s@google.com --subject="%s" --body_file=%s' % 89 (sendgmr_path, email_to, subject, report_file)) 90 command_executer.GetCommandExecuter().RunCommand(command) 91 92 93def PruneOldFailures(failure_dict, int_date): 94 earliest_date = int_date - MAX_SAVE_RECORDS 95 for suite in failure_dict: 96 suite_dict = failure_dict[suite] 97 test_keys_to_remove = [] 98 for test in suite_dict: 99 test_dict = suite_dict[test] 100 msg_keys_to_remove = [] 101 for msg in test_dict: 102 fails = test_dict[msg] 103 i = 0 104 while i < len(fails) and fails[i][0] <= earliest_date: 105 i += 1 106 new_fails = fails[i:] 107 test_dict[msg] = new_fails 108 if len(new_fails) == 0: 109 msg_keys_to_remove.append(msg) 110 111 for k in msg_keys_to_remove: 112 del test_dict[k] 113 114 suite_dict[test] = test_dict 115 if len(test_dict) == 0: 116 test_keys_to_remove.append(test) 117 118 for k in test_keys_to_remove: 119 del suite_dict[k] 120 121 failure_dict[suite] = suite_dict 122 123 124def GenerateWaterfallReport(report_dict, fail_dict, waterfall_type, date): 125 """Write out the actual formatted report.""" 126 127 filename = 'waterfall_report.%s_waterfall.%s.txt' % (waterfall_type, date) 128 129 date_string = '' 130 date_list = report_dict['date'] 131 num_dates = len(date_list) 132 i = 0 133 for d in date_list: 134 date_string += d 135 if i < num_dates - 1: 136 date_string += ', ' 137 i += 1 138 139 if waterfall_type == 'main': 140 report_list = WATERFALL_BUILDERS 141 else: 142 report_list = report_dict.keys() 143 144 with open(filename, 'w') as out_file: 145 # Write Report Header 146 out_file.write('\nStatus of %s Waterfall Builds from %s\n\n' % 147 (waterfall_type, date_string)) 148 out_file.write(' ' 149 ' kernel kernel\n') 150 out_file.write(' Build bvt- bvt-cq ' 151 'toolchain- security daily daily\n') 152 out_file.write(' status inline ' 153 ' tests regression benchmarks\n') 154 out_file.write(' [P/ F/ DR]* [P/ F /DR]* ' 155 '[P/ F/ DR]* [P/ F/ DR]* [P/ F/ DR]* [P/ F/ DR]*\n\n') 156 157 # Write daily waterfall status section. 158 for i in range(0, len(report_list)): 159 builder = report_list[i] 160 if builder == 'date': 161 continue 162 163 if builder not in report_dict: 164 out_file.write('Unable to find information for %s.\n\n' % builder) 165 continue 166 167 build_dict = report_dict[builder] 168 status = build_dict.get('build_status', 'bad') 169 inline = build_dict.get('bvt-inline', '[??/ ?? /??]') 170 cq = build_dict.get('bvt-cq', '[??/ ?? /??]') 171 inline_color = build_dict.get('bvt-inline-color', '') 172 cq_color = build_dict.get('bvt-cq-color', '') 173 if 'x86' not in builder: 174 toolchain = build_dict.get('toolchain-tests', '[??/ ?? /??]') 175 security = build_dict.get('security', '[??/ ?? /??]') 176 toolchain_color = build_dict.get('toolchain-tests-color', '') 177 security_color = build_dict.get('security-color', '') 178 if 'gcc' in builder: 179 regression = build_dict.get('kernel_daily_regression', '[??/ ?? /??]') 180 bench = build_dict.get('kernel_daily_benchmarks', '[??/ ?? /??]') 181 regression_color = build_dict.get('kernel_daily_regression-color', '') 182 bench_color = build_dict.get('kernel_daily_benchmarks-color', '') 183 out_file.write(' %6s %6s' 184 ' %6s %6s %6s %6s\n' % 185 (inline_color, cq_color, toolchain_color, 186 security_color, regression_color, bench_color)) 187 out_file.write('%25s %3s %s %s %s %s %s %s\n' % (builder, status, 188 inline, cq, 189 toolchain, security, 190 regression, bench)) 191 else: 192 out_file.write(' %6s %6s' 193 ' %6s %6s\n' % (inline_color, cq_color, 194 toolchain_color, 195 security_color)) 196 out_file.write('%25s %3s %s %s %s %s\n' % (builder, status, inline, 197 cq, toolchain, security)) 198 else: 199 out_file.write(' %6s %6s\n' % 200 (inline_color, cq_color)) 201 out_file.write('%25s %3s %s %s\n' % (builder, status, inline, cq)) 202 if 'build_link' in build_dict: 203 out_file.write('%s\n\n' % build_dict['build_link']) 204 205 out_file.write('\n\n*P = Number of tests in suite that Passed; F = ' 206 'Number of tests in suite that Failed; DR = Number of tests' 207 ' in suite that Didn\'t Run.\n') 208 209 # Write failure report section. 210 out_file.write('\n\nSummary of Test Failures as of %s\n\n' % date_string) 211 212 # We want to sort the errors and output them in order of the ones that occur 213 # most often. So we have to collect the data about all of them, then sort 214 # it. 215 error_groups = [] 216 for suite in fail_dict: 217 suite_dict = fail_dict[suite] 218 if suite_dict: 219 for test in suite_dict: 220 test_dict = suite_dict[test] 221 for err_msg in test_dict: 222 err_list = test_dict[err_msg] 223 sorted_list = sorted(err_list, key=lambda x: x[0], reverse=True) 224 err_group = [len(sorted_list), suite, test, err_msg, sorted_list] 225 error_groups.append(err_group) 226 227 # Sort the errors by the number of errors of each type. Then output them in 228 # order. 229 sorted_errors = sorted(error_groups, key=lambda x: x[0], reverse=True) 230 for i in range(0, len(sorted_errors)): 231 err_group = sorted_errors[i] 232 suite = err_group[1] 233 test = err_group[2] 234 err_msg = err_group[3] 235 err_list = err_group[4] 236 out_file.write('Suite: %s\n' % suite) 237 out_file.write(' %s (%d failures)\n' % (test, len(err_list))) 238 out_file.write(' (%s)\n' % err_msg) 239 for i in range(0, len(err_list)): 240 err = err_list[i] 241 out_file.write(' %s, %s, %s\n' % (format_date(err[0]), err[1], 242 err[2])) 243 out_file.write('\n') 244 245 print('Report generated in %s.' % filename) 246 return filename 247 248 249def UpdateReport(report_dict, builder, test, report_date, build_link, 250 test_summary, board, color): 251 """Update the data in our report dictionary with current test's data.""" 252 253 if 'date' not in report_dict: 254 report_dict['date'] = [report_date] 255 elif report_date not in report_dict['date']: 256 # It is possible that some of the builders started/finished on different 257 # days, so we allow for multiple dates in the reports. 258 report_dict['date'].append(report_date) 259 260 build_key = '' 261 if builder == 'gcc_toolchain': 262 build_key = '%s-gcc-toolchain' % board 263 elif builder == 'llvm_toolchain': 264 build_key = '%s-llvm-toolchain' % board 265 else: 266 build_key = builder 267 268 if build_key not in report_dict.keys(): 269 build_dict = dict() 270 else: 271 build_dict = report_dict[build_key] 272 273 if 'build_link' not in build_dict: 274 build_dict['build_link'] = build_link 275 276 if 'date' not in build_dict: 277 build_dict['date'] = report_date 278 279 if 'board' in build_dict and build_dict['board'] != board: 280 raise RuntimeError('Error: Two different boards (%s,%s) in one build (%s)!' 281 % (board, build_dict['board'], build_link)) 282 build_dict['board'] = board 283 284 color_key = '%s-color' % test 285 build_dict[color_key] = color 286 287 # Check to see if we already have a build status for this build_key 288 status = '' 289 if 'build_status' in build_dict.keys(): 290 # Use current build_status, unless current test failed (see below). 291 status = build_dict['build_status'] 292 293 if not test_summary: 294 # Current test data was not available, so something was bad with build. 295 build_dict['build_status'] = 'bad' 296 build_dict[test] = '[ no data ]' 297 else: 298 build_dict[test] = test_summary 299 if not status: 300 # Current test ok; no other data, so assume build was ok. 301 build_dict['build_status'] = 'ok' 302 303 report_dict[build_key] = build_dict 304 305 306def UpdateBuilds(builds): 307 """Update the data in our build-data.txt file.""" 308 309 # The build data file records the last build number for which we 310 # generated a report. When we generate the next report, we read 311 # this data and increment it to get the new data; when we finish 312 # generating the reports, we write the updated values into this file. 313 # NOTE: One side effect of doing this at the end: If the script 314 # fails in the middle of generating a report, this data does not get 315 # updated. 316 with open(BUILD_DATA_FILE, 'w') as fp: 317 gcc_max = 0 318 llvm_max = 0 319 for b in builds: 320 if b[0] == 'gcc_toolchain': 321 gcc_max = max(gcc_max, b[1]) 322 elif b[0] == 'llvm_toolchain': 323 llvm_max = max(llvm_max, b[1]) 324 else: 325 fp.write('%s,%d\n' % (b[0], b[1])) 326 if gcc_max > 0: 327 fp.write('gcc_toolchain,%d\n' % gcc_max) 328 if llvm_max > 0: 329 fp.write('llvm_toolchain,%d\n' % llvm_max) 330 331 332def GetBuilds(): 333 """Read build-data.txt to determine values for current report.""" 334 335 # Read the values of the last builds used to generate a report, and 336 # increment them appropriately, to get values for generating the 337 # current report. (See comments in UpdateBuilds). 338 with open(BUILD_DATA_FILE, 'r') as fp: 339 lines = fp.readlines() 340 341 builds = [] 342 for l in lines: 343 l = l.rstrip() 344 words = l.split(',') 345 builder = words[0] 346 build = int(words[1]) 347 builds.append((builder, build + 1)) 348 # NOTE: We are assuming here that there are always 2 daily builds in 349 # each of the rotating builders. I am not convinced this is a valid 350 # assumption. 351 if builder == 'gcc_toolchain' or builder == 'llvm_toolchain': 352 builds.append((builder, build + 2)) 353 354 return builds 355 356 357def RecordFailures(failure_dict, platform, suite, builder, int_date, log_file, 358 build_num, failed): 359 """Read and update the stored data about test failures.""" 360 361 # Get the dictionary for this particular test suite from the failures 362 # dictionary. 363 suite_dict = failure_dict[suite] 364 365 # Read in the entire log file for this test/build. 366 with open(log_file, 'r') as in_file: 367 lines = in_file.readlines() 368 369 # Update the entries in the failure dictionary for each test within this suite 370 # that failed. 371 for test in failed: 372 # Check to see if there is already an entry in the suite dictionary for this 373 # test; if so use that, otherwise create a new entry. 374 if test in suite_dict: 375 test_dict = suite_dict[test] 376 else: 377 test_dict = dict() 378 # Parse the lines from the log file, looking for lines that indicate this 379 # test failed. 380 msg = '' 381 for l in lines: 382 words = l.split() 383 if len(words) < 3: 384 continue 385 if ((words[0] == test and words[1] == 'ERROR:') or 386 (words[0] == 'provision' and words[1] == 'FAIL:')): 387 words = words[2:] 388 # Get the error message for the failure. 389 msg = ' '.join(words) 390 if not msg: 391 msg = 'Unknown_Error' 392 393 # Look for an existing entry for this error message in the test dictionary. 394 # If found use that, otherwise create a new entry for this error message. 395 if msg in test_dict: 396 error_list = test_dict[msg] 397 else: 398 error_list = list() 399 # Create an entry for this new failure 400 new_item = [int_date, platform, builder, build_num] 401 # Add this failure to the error list if it's not already there. 402 if new_item not in error_list: 403 error_list.append([int_date, platform, builder, build_num]) 404 # Sort the error list by date. 405 error_list.sort(key=lambda x: x[0]) 406 # Calculate the earliest date to save; delete records for older failures. 407 earliest_date = int_date - MAX_SAVE_RECORDS 408 i = 0 409 while i < len(error_list) and error_list[i][0] <= earliest_date: 410 i += 1 411 if i > 0: 412 error_list = error_list[i:] 413 # Save the error list in the test's dictionary, keyed on error_msg. 414 test_dict[msg] = error_list 415 416 # Save the updated test dictionary in the test_suite dictionary. 417 suite_dict[test] = test_dict 418 419 # Save the updated test_suite dictionary in the failure dictionary. 420 failure_dict[suite] = suite_dict 421 422 423def ParseLogFile(log_file, test_data_dict, failure_dict, test, builder, 424 build_num, build_link): 425 """Parse the log file from the given builder, build_num and test. 426 427 Also adds the results for this test to our test results dictionary, 428 and calls RecordFailures, to update our test failure data. 429 """ 430 431 lines = [] 432 with open(log_file, 'r') as infile: 433 lines = infile.readlines() 434 435 passed = {} 436 failed = {} 437 not_run = {} 438 date = '' 439 status = '' 440 board = '' 441 num_provision_errors = 0 442 build_ok = True 443 afe_line = '' 444 445 for line in lines: 446 if line.rstrip() == '<title>404 Not Found</title>': 447 print('Warning: File for %s (build number %d), %s was not found.' % 448 (builder, build_num, test)) 449 build_ok = False 450 break 451 if '[ PASSED ]' in line: 452 test_name = line.split()[0] 453 if test_name != 'Suite': 454 passed[test_name] = True 455 elif '[ FAILED ]' in line: 456 test_name = line.split()[0] 457 if test_name == 'provision': 458 num_provision_errors += 1 459 not_run[test_name] = True 460 elif test_name != 'Suite': 461 failed[test_name] = True 462 elif line.startswith('started: '): 463 date = line.rstrip() 464 date = date[9:] 465 date_obj = time.strptime(date, '%a %b %d %H:%M:%S %Y') 466 int_date = ( 467 date_obj.tm_year * 10000 + date_obj.tm_mon * 100 + date_obj.tm_mday) 468 date = time.strftime('%a %b %d %Y', date_obj) 469 elif not status and line.startswith('status: '): 470 status = line.rstrip() 471 words = status.split(':') 472 status = words[-1] 473 elif line.find('Suite passed with a warning') != -1: 474 status = 'WARNING' 475 elif line.startswith('@@@STEP_LINK@Link to suite@'): 476 afe_line = line.rstrip() 477 words = afe_line.split('@') 478 for w in words: 479 if w.startswith('http'): 480 afe_line = w 481 afe_line = afe_line.replace('&', '&') 482 elif 'INFO: RunCommand:' in line: 483 words = line.split() 484 for i in range(0, len(words) - 1): 485 if words[i] == '--board': 486 board = words[i + 1] 487 488 test_dict = test_data_dict[test] 489 test_list = test_dict['tests'] 490 491 if build_ok: 492 for t in test_list: 493 if not t in passed and not t in failed: 494 not_run[t] = True 495 496 total_pass = len(passed) 497 total_fail = len(failed) 498 total_notrun = len(not_run) 499 500 else: 501 total_pass = 0 502 total_fail = 0 503 total_notrun = 0 504 status = 'Not found.' 505 if not build_ok: 506 return [], date, board, 0, ' ' 507 508 build_dict = dict() 509 build_dict['id'] = build_num 510 build_dict['builder'] = builder 511 build_dict['date'] = date 512 build_dict['build_link'] = build_link 513 build_dict['total_pass'] = total_pass 514 build_dict['total_fail'] = total_fail 515 build_dict['total_not_run'] = total_notrun 516 build_dict['afe_job_link'] = afe_line 517 build_dict['provision_errors'] = num_provision_errors 518 if status.strip() == 'SUCCESS': 519 build_dict['color'] = 'green ' 520 elif status.strip() == 'FAILURE': 521 build_dict['color'] = ' red ' 522 elif status.strip() == 'WARNING': 523 build_dict['color'] = 'orange' 524 else: 525 build_dict['color'] = ' ' 526 527 # Use YYYYMMDD (integer) as the build record key 528 if build_ok: 529 if board in test_dict: 530 board_dict = test_dict[board] 531 else: 532 board_dict = dict() 533 board_dict[int_date] = build_dict 534 535 # Only keep the last 5 records (based on date) 536 keys_list = board_dict.keys() 537 if len(keys_list) > MAX_SAVE_RECORDS: 538 min_key = min(keys_list) 539 del board_dict[min_key] 540 541 # Make sure changes get back into the main dictionary 542 test_dict[board] = board_dict 543 test_data_dict[test] = test_dict 544 545 if len(failed) > 0: 546 RecordFailures(failure_dict, board, test, builder, int_date, log_file, 547 build_num, failed) 548 549 summary_result = '[%2d/ %2d/ %2d]' % (total_pass, total_fail, total_notrun) 550 551 return summary_result, date, board, int_date, build_dict['color'] 552 553 554def DownloadLogFile(builder, buildnum, test, test_family): 555 556 ce = command_executer.GetCommandExecuter() 557 os.system('mkdir -p %s/%s/%s' % (DOWNLOAD_DIR, builder, test)) 558 if builder == 'gcc_toolchain' or builder == 'llvm_toolchain': 559 source = ('https://uberchromegw.corp.google.com/i/chromiumos.tryserver' 560 '/builders/%s/builds/%d/steps/%s%%20%%5B%s%%5D/logs/stdio' % 561 (builder, buildnum, test_family, test)) 562 build_link = ('https://uberchromegw.corp.google.com/i/chromiumos.tryserver' 563 '/builders/%s/builds/%d' % (builder, buildnum)) 564 else: 565 source = ('https://uberchromegw.corp.google.com/i/chromeos/builders/%s/' 566 'builds/%d/steps/%s%%20%%5B%s%%5D/logs/stdio' % 567 (builder, buildnum, test_family, test)) 568 build_link = ('https://uberchromegw.corp.google.com/i/chromeos/builders/%s' 569 '/builds/%d' % (builder, buildnum)) 570 571 target = '%s/%s/%s/%d' % (DOWNLOAD_DIR, builder, test, buildnum) 572 if not os.path.isfile(target) or os.path.getsize(target) == 0: 573 cmd = 'sso_client %s > %s' % (source, target) 574 status = ce.RunCommand(cmd) 575 if status != 0: 576 return '', '' 577 578 return target, build_link 579 580 581# Check for prodaccess. 582def CheckProdAccess(): 583 status, output, _ = command_executer.GetCommandExecuter().RunCommandWOutput( 584 'prodcertstatus') 585 if status != 0: 586 return False 587 # Verify that status is not expired 588 if 'expires' in output: 589 return True 590 return False 591 592 593def Main(): 594 """Main function for this script.""" 595 596 test_data_dict = dict() 597 failure_dict = dict() 598 599 prod_access = CheckProdAccess() 600 if not prod_access: 601 print('ERROR: Please run prodaccess first.') 602 return 603 604 with open('%s/waterfall-test-data.json' % DATA_DIR, 'r') as input_file: 605 test_data_dict = json.load(input_file) 606 607 with open('%s/test-failure-data.json' % DATA_DIR, 'r') as fp: 608 failure_dict = json.load(fp) 609 610 builds = GetBuilds() 611 612 waterfall_report_dict = dict() 613 rotating_report_dict = dict() 614 int_date = 0 615 for test_desc in TESTS: 616 test, test_family = test_desc 617 for build in builds: 618 (builder, buildnum) = build 619 if test.startswith('kernel') and 'llvm' in builder: 620 continue 621 if 'x86' in builder and not test.startswith('bvt'): 622 continue 623 target, build_link = DownloadLogFile(builder, buildnum, test, test_family) 624 625 if os.path.exists(target): 626 test_summary, report_date, board, tmp_date, color = ParseLogFile( 627 target, test_data_dict, failure_dict, test, builder, buildnum, 628 build_link) 629 630 if tmp_date != 0: 631 int_date = tmp_date 632 633 if builder in ROTATING_BUILDERS: 634 UpdateReport(rotating_report_dict, builder, test, report_date, 635 build_link, test_summary, board, color) 636 else: 637 UpdateReport(waterfall_report_dict, builder, test, report_date, 638 build_link, test_summary, board, color) 639 640 PruneOldFailures(failure_dict, int_date) 641 642 if waterfall_report_dict: 643 main_report = GenerateWaterfallReport(waterfall_report_dict, failure_dict, 644 'main', int_date) 645 EmailReport(main_report, 'Main', format_date(int_date)) 646 shutil.copy(main_report, ARCHIVE_DIR) 647 if rotating_report_dict: 648 rotating_report = GenerateWaterfallReport(rotating_report_dict, 649 failure_dict, 'rotating', 650 int_date) 651 EmailReport(rotating_report, 'Rotating', format_date(int_date)) 652 shutil.copy(rotating_report, ARCHIVE_DIR) 653 654 with open('%s/waterfall-test-data.json' % DATA_DIR, 'w') as out_file: 655 json.dump(test_data_dict, out_file, indent=2) 656 657 with open('%s/test-failure-data.json' % DATA_DIR, 'w') as out_file: 658 json.dump(failure_dict, out_file, indent=2) 659 660 UpdateBuilds(builds) 661 662 663if __name__ == '__main__': 664 Main() 665 sys.exit(0) 666