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('&amp;', '&')
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