Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
| 2 # Copyright (c) 2013 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2013 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 5 | 5 |
| 6 """Performance Test Bisect Tool | 6 """Performance Test Bisect Tool |
| 7 | 7 |
| 8 This script bisects a series of changelists using binary search. It starts at | 8 This script bisects a series of changelists using binary search. It starts at |
| 9 a bad revision where a performance metric has regressed, and asks for a last | 9 a bad revision where a performance metric has regressed, and asks for a last |
| 10 known-good revision. It will then binary search across this revision range by | 10 known-good revision. It will then binary search across this revision range by |
| (...skipping 34 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 45 import re | 45 import re |
| 46 import shlex | 46 import shlex |
| 47 import shutil | 47 import shutil |
| 48 import StringIO | 48 import StringIO |
| 49 import subprocess | 49 import subprocess |
| 50 import sys | 50 import sys |
| 51 import time | 51 import time |
| 52 import zipfile | 52 import zipfile |
| 53 | 53 |
| 54 import bisect_utils | 54 import bisect_utils |
| 55 import post_perf_builder_job | |
| 56 | |
| 55 | 57 |
| 56 try: | 58 try: |
| 57 from telemetry.page import cloud_storage | 59 from telemetry.page import cloud_storage |
| 58 except ImportError: | 60 except ImportError: |
| 59 sys.path.append(os.path.join(os.path.dirname(sys.argv[0]), 'telemetry')) | 61 sys.path.append(os.path.join(os.path.dirname(sys.argv[0]), 'telemetry')) |
| 60 from telemetry.page import cloud_storage | 62 from telemetry.page import cloud_storage |
| 61 | 63 |
| 62 # The additional repositories that might need to be bisected. | 64 # The additional repositories that might need to be bisected. |
| 63 # If the repository has any dependant repositories (such as skia/src needs | 65 # If the repository has any dependant repositories (such as skia/src needs |
| 64 # skia/include and skia/gyp to be updated), specify them in the 'depends' | 66 # skia/include and skia/gyp to be updated), specify them in the 'depends' |
| (...skipping 1241 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 1306 source_dir = os.path.join(build_dir, build_type) | 1308 source_dir = os.path.join(build_dir, build_type) |
| 1307 destination_dir = os.path.join(build_dir, '%s.bak' % build_type) | 1309 destination_dir = os.path.join(build_dir, '%s.bak' % build_type) |
| 1308 if restore: | 1310 if restore: |
| 1309 source_dir, destination_dir = destination_dir, source_dir | 1311 source_dir, destination_dir = destination_dir, source_dir |
| 1310 if os.path.exists(source_dir): | 1312 if os.path.exists(source_dir): |
| 1311 RmTreeAndMkDir(destination_dir, skip_makedir=True) | 1313 RmTreeAndMkDir(destination_dir, skip_makedir=True) |
| 1312 shutil.move(source_dir, destination_dir) | 1314 shutil.move(source_dir, destination_dir) |
| 1313 return destination_dir | 1315 return destination_dir |
| 1314 return None | 1316 return None |
| 1315 | 1317 |
| 1316 def DownloadCurrentBuild(self, sha_revision, build_type='Release'): | 1318 def DownloadCurrentBuild(self, revision, build_type='Release'): |
| 1317 """Download the build archive for the given revision. | 1319 """Download the build archive for the given revision. |
| 1318 | 1320 |
| 1319 Args: | 1321 Args: |
| 1320 sha_revision: The git SHA1 for the revision. | 1322 revision: The SVN revision to build. |
| 1321 build_type: Target build type ('Release', 'Debug', 'Release_x64' etc.) | 1323 build_type: Target build type ('Release', 'Debug', 'Release_x64' etc.) |
| 1322 | 1324 |
| 1323 Returns: | 1325 Returns: |
| 1324 True if download succeeds, otherwise False. | 1326 True if download succeeds, otherwise False. |
| 1325 """ | 1327 """ |
| 1326 # Get SVN revision for the given SHA, since builds are archived using SVN | |
| 1327 # revision. | |
| 1328 revision = self.source_control.SVNFindRev(sha_revision) | |
| 1329 if not revision: | |
| 1330 raise RuntimeError( | |
| 1331 'Failed to determine SVN revision for %s' % sha_revision) | |
| 1332 | |
| 1333 abs_build_dir = os.path.abspath( | 1328 abs_build_dir = os.path.abspath( |
| 1334 self.builder.GetBuildOutputDirectory(self.opts, self.src_cwd)) | 1329 self.builder.GetBuildOutputDirectory(self.opts, self.src_cwd)) |
| 1335 target_build_output_dir = os.path.join(abs_build_dir, build_type) | 1330 target_build_output_dir = os.path.join(abs_build_dir, build_type) |
| 1336 # Get build target architecture. | 1331 # Get build target architecture. |
| 1337 build_arch = self.opts.target_arch | 1332 build_arch = self.opts.target_arch |
| 1338 # File path of the downloaded archive file. | 1333 # File path of the downloaded archive file. |
| 1339 archive_file_dest = os.path.join(abs_build_dir, | 1334 archive_file_dest = os.path.join(abs_build_dir, |
| 1340 GetZipFileName(revision, build_arch)) | 1335 GetZipFileName(revision, build_arch)) |
| 1341 if FetchFromCloudStorage(self.opts.gs_bucket, | 1336 remote_build = GetRemoteBuildPath(revision, build_arch) |
| 1342 GetRemoteBuildPath(revision, build_arch), | 1337 fetch_build_func = lambda: FetchFromCloudStorage(self.opts.gs_bucket, |
| 1343 abs_build_dir): | 1338 remote_build, |
| 1344 # Generic name for the archive, created when archive file is extracted. | 1339 abs_build_dir) |
| 1345 output_dir = os.path.join(abs_build_dir, | 1340 if not fetch_build_func(): |
| 1346 GetZipFileName(target_arch=build_arch)) | 1341 if not self.PostBuildRequestAndWait(revision, condition=fetch_build_func): |
| 1347 # Unzip build archive directory. | 1342 raise RuntimeError('Somewthing went wrong while processing build' |
| 1348 try: | 1343 'request for: %s' % revision) |
| 1344 | |
| 1345 # Generic name for the archive, created when archive file is extracted. | |
| 1346 output_dir = os.path.join(abs_build_dir, | |
| 1347 GetZipFileName(target_arch=build_arch)) | |
| 1348 # Unzip build archive directory. | |
| 1349 try: | |
| 1350 RmTreeAndMkDir(output_dir, skip_makedir=True) | |
| 1351 ExtractZip(archive_file_dest, abs_build_dir) | |
| 1352 if os.path.exists(output_dir): | |
| 1353 self.BackupOrRestoreOutputdirectory(restore=False) | |
| 1354 print 'Moving build from %s to %s' % ( | |
| 1355 output_dir, target_build_output_dir) | |
| 1356 shutil.move(output_dir, target_build_output_dir) | |
| 1357 return True | |
| 1358 raise IOError('Missing extracted folder %s ' % output_dir) | |
| 1359 except e: | |
| 1360 print 'Somewthing went wrong while extracting archive file: %s' % e | |
| 1361 self.BackupOrRestoreOutputdirectory(restore=True) | |
| 1362 # Cleanup any leftovers from unzipping. | |
| 1363 if os.path.exists(output_dir): | |
| 1349 RmTreeAndMkDir(output_dir, skip_makedir=True) | 1364 RmTreeAndMkDir(output_dir, skip_makedir=True) |
| 1350 ExtractZip(archive_file_dest, abs_build_dir) | 1365 finally: |
| 1351 if os.path.exists(output_dir): | 1366 # Delete downloaded archive |
| 1352 self.BackupOrRestoreOutputdirectory(restore=False) | 1367 if os.path.exists(archive_file_dest): |
| 1353 print 'Moving build from %s to %s' % ( | 1368 os.remove(archive_file_dest) |
| 1354 output_dir, target_build_output_dir) | 1369 return False |
| 1355 shutil.move(output_dir, target_build_output_dir) | 1370 |
| 1356 return True | 1371 def PostBuildRequestAndWait(self, revision, condition, patch=None): |
| 1357 raise IOError('Missing extracted folder %s ' % output_dir) | 1372 """POSTs the build request job to the tryserver instance.""" |
| 1358 except e: | 1373 |
| 1359 print 'Somewthing went wrong while extracting archive file: %s' % e | 1374 def GetBuilderNameAndBuildTime(target_arch='ia32'): |
| 1360 self.BackupOrRestoreOutputdirectory(restore=True) | 1375 """Gets builder name and buildtime in seconds based on platform.""" |
| 1361 # Cleanup any leftovers from unzipping. | 1376 if IsWindows(): |
| 1362 if os.path.exists(output_dir): | 1377 if Is64BitWindows() and target_arch == 'x64': |
| 1363 RmTreeAndMkDir(output_dir, skip_makedir=True) | 1378 return ('Win x64 Bisect Builder', 3600) |
| 1364 finally: | 1379 return ('Win Bisect Builder', 3600) |
| 1365 # Delete downloaded archive | 1380 if IsLinux(): |
| 1366 if os.path.exists(archive_file_dest): | 1381 return ('Linux Bisect Builder', 1800) |
| 1367 os.remove(archive_file_dest) | 1382 if IsMac(): |
| 1383 return ('Mac Bisect Builder', 2700) | |
| 1384 raise NotImplementedError('Unsupported Platform "%s".' % sys.platform) | |
| 1385 if not condition: | |
| 1386 return False | |
| 1387 | |
| 1388 bot_name, build_timeout = GetBuilderNameAndBuildTime(self.opts.target_arch) | |
| 1389 | |
| 1390 # Creates a try job description. | |
| 1391 job_args = {'host': self.opts.builder_host, | |
| 1392 'port': self.opts.builder_port, | |
| 1393 'revision': revision, | |
| 1394 'bot': bot_name, | |
| 1395 'name': 'Bisect Job-%s' % revision | |
| 1396 } | |
| 1397 # Update patch information if supplied. | |
| 1398 if patch: | |
| 1399 job_args['patch'] = patch | |
| 1400 # Posts job to build the revision on the server. | |
| 1401 if post_perf_builder_job.PostTryJob(job_args): | |
| 1402 poll_interval = 60 | |
| 1403 start_time = time.time() | |
| 1404 while True: | |
| 1405 res = condition() | |
| 1406 if res: | |
| 1407 return res | |
| 1408 elapsed_time = time.time() - start_time | |
| 1409 if elapsed_time > build_timeout: | |
|
shatch
2014/03/11 17:20:54
A timeout seems fair enough for now I think. I can
prasadv
2014/03/13 01:03:26
To handle load, we requested 16 new builder bots o
| |
| 1410 raise RuntimeError('Timed out while waiting %ds for %s build.' % | |
| 1411 (build_timeout, revision)) | |
| 1412 print ('Time elapsed: %ss, still waiting for %s build' % | |
| 1413 (elapsed_time, revision)) | |
| 1414 time.sleep(poll_interval) | |
| 1368 return False | 1415 return False |
| 1369 | 1416 |
| 1370 def BuildCurrentRevision(self, depot, revision=None): | 1417 def BuildCurrentRevision(self, depot, revision=None): |
| 1371 """Builds chrome and performance_ui_tests on the current revision. | 1418 """Builds chrome and performance_ui_tests on the current revision. |
| 1372 | 1419 |
| 1373 Returns: | 1420 Returns: |
| 1374 True if the build was successful. | 1421 True if the build was successful. |
| 1375 """ | 1422 """ |
| 1376 if self.opts.debug_ignore_build: | 1423 if self.opts.debug_ignore_build: |
| 1377 return True | 1424 return True |
| 1378 cwd = os.getcwd() | 1425 cwd = os.getcwd() |
| 1379 os.chdir(self.src_cwd) | 1426 os.chdir(self.src_cwd) |
| 1380 # Fetch build archive for the given revision from the cloud storage when | 1427 # Fetch build archive for the given revision from the cloud storage when |
| 1381 # the storage bucket is passed. | 1428 # the storage bucket is passed. |
| 1382 if depot == 'chromium' and self.opts.gs_bucket and revision: | 1429 if depot == 'chromium' and self.opts.gs_bucket and revision: |
| 1430 # Get SVN revision for the given SHA, since builds are archived using SVN | |
| 1431 # revision. | |
| 1432 revision = self.source_control.SVNFindRev(revision) | |
| 1433 if not revision: | |
| 1434 raise RuntimeError( | |
| 1435 'Failed to determine SVN revision for %s' % sha_revision) | |
| 1383 if self.DownloadCurrentBuild(revision): | 1436 if self.DownloadCurrentBuild(revision): |
| 1384 os.chdir(cwd) | 1437 os.chdir(cwd) |
| 1385 return True | 1438 return True |
| 1386 raise RuntimeError('Failed to download build archive for revision %s.\n' | 1439 raise RuntimeError('Failed to download build archive for revision %s.\n' |
| 1387 'Unfortunately, bisection couldn\'t continue any ' | 1440 'Unfortunately, bisection couldn\'t continue any ' |
| 1388 'further. Please try running script without ' | 1441 'further. Please try running script without ' |
| 1389 '--gs_bucket flag to produce local builds.' % revision) | 1442 '--gs_bucket flag to produce local builds.' % revision) |
| 1390 | 1443 |
| 1444 | |
| 1391 build_success = self.builder.Build(depot, self.opts) | 1445 build_success = self.builder.Build(depot, self.opts) |
| 1392 os.chdir(cwd) | 1446 os.chdir(cwd) |
| 1393 return build_success | 1447 return build_success |
| 1394 | 1448 |
| 1395 def RunGClientHooks(self): | 1449 def RunGClientHooks(self): |
| 1396 """Runs gclient with runhooks command. | 1450 """Runs gclient with runhooks command. |
| 1397 | 1451 |
| 1398 Returns: | 1452 Returns: |
| 1399 True if gclient reports no errors. | 1453 True if gclient reports no errors. |
| 1400 """ | 1454 """ |
| (...skipping 1614 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 3015 self.command = None | 3069 self.command = None |
| 3016 self.output_buildbot_annotations = None | 3070 self.output_buildbot_annotations = None |
| 3017 self.no_custom_deps = False | 3071 self.no_custom_deps = False |
| 3018 self.working_directory = None | 3072 self.working_directory = None |
| 3019 self.extra_src = None | 3073 self.extra_src = None |
| 3020 self.debug_ignore_build = None | 3074 self.debug_ignore_build = None |
| 3021 self.debug_ignore_sync = None | 3075 self.debug_ignore_sync = None |
| 3022 self.debug_ignore_perf_test = None | 3076 self.debug_ignore_perf_test = None |
| 3023 self.gs_bucket = None | 3077 self.gs_bucket = None |
| 3024 self.target_arch = 'ia32' | 3078 self.target_arch = 'ia32' |
| 3079 self.builder_host = None | |
| 3080 self.builder_port = None | |
| 3025 | 3081 |
| 3026 def _CreateCommandLineParser(self): | 3082 def _CreateCommandLineParser(self): |
| 3027 """Creates a parser with bisect options. | 3083 """Creates a parser with bisect options. |
| 3028 | 3084 |
| 3029 Returns: | 3085 Returns: |
| 3030 An instance of optparse.OptionParser. | 3086 An instance of optparse.OptionParser. |
| 3031 """ | 3087 """ |
| 3032 usage = ('%prog [options] [-- chromium-options]\n' | 3088 usage = ('%prog [options] [-- chromium-options]\n' |
| 3033 'Perform binary search on revision history to find a minimal ' | 3089 'Perform binary search on revision history to find a minimal ' |
| 3034 'range of revisions where a peformance metric regressed.\n') | 3090 'range of revisions where a peformance metric regressed.\n') |
| (...skipping 93 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 3128 type='str', | 3184 type='str', |
| 3129 help=('Name of Google Storage bucket to upload or ' | 3185 help=('Name of Google Storage bucket to upload or ' |
| 3130 'download build. e.g., chrome-perf')) | 3186 'download build. e.g., chrome-perf')) |
| 3131 group.add_option('--target_arch', | 3187 group.add_option('--target_arch', |
| 3132 type='choice', | 3188 type='choice', |
| 3133 choices=['ia32', 'x64', 'arm'], | 3189 choices=['ia32', 'x64', 'arm'], |
| 3134 default='ia32', | 3190 default='ia32', |
| 3135 dest='target_arch', | 3191 dest='target_arch', |
| 3136 help=('The target build architecture. Choices are "ia32" ' | 3192 help=('The target build architecture. Choices are "ia32" ' |
| 3137 '(default), "x64" or "arm".')) | 3193 '(default), "x64" or "arm".')) |
| 3138 | 3194 group.add_option('--builder_host', |
| 3195 dest='builder_host', | |
| 3196 type='str', | |
| 3197 help=('Host address of server to produce build by posting' | |
| 3198 ' try job request.')) | |
| 3199 group.add_option('--builder_port', | |
| 3200 dest='builder_port', | |
| 3201 type='int', | |
| 3202 help=('HTTP port of the server to produce build by posting' | |
| 3203 ' try job request.')) | |
| 3139 parser.add_option_group(group) | 3204 parser.add_option_group(group) |
| 3140 | 3205 |
| 3141 group = optparse.OptionGroup(parser, 'Debug options') | 3206 group = optparse.OptionGroup(parser, 'Debug options') |
| 3142 group.add_option('--debug_ignore_build', | 3207 group.add_option('--debug_ignore_build', |
| 3143 action="store_true", | 3208 action="store_true", |
| 3144 help='DEBUG: Don\'t perform builds.') | 3209 help='DEBUG: Don\'t perform builds.') |
| 3145 group.add_option('--debug_ignore_sync', | 3210 group.add_option('--debug_ignore_sync', |
| 3146 action="store_true", | 3211 action="store_true", |
| 3147 help='DEBUG: Don\'t perform syncs.') | 3212 help='DEBUG: Don\'t perform syncs.') |
| 3148 group.add_option('--debug_ignore_perf_test', | 3213 group.add_option('--debug_ignore_perf_test', |
| 3149 action="store_true", | 3214 action="store_true", |
| 3150 help='DEBUG: Don\'t perform performance tests.') | 3215 help='DEBUG: Don\'t perform performance tests.') |
| 3151 parser.add_option_group(group) | 3216 parser.add_option_group(group) |
| 3152 | |
| 3153 | |
| 3154 return parser | 3217 return parser |
| 3155 | 3218 |
| 3156 def ParseCommandLine(self): | 3219 def ParseCommandLine(self): |
| 3157 """Parses the command line for bisect options.""" | 3220 """Parses the command line for bisect options.""" |
| 3158 parser = self._CreateCommandLineParser() | 3221 parser = self._CreateCommandLineParser() |
| 3159 (opts, args) = parser.parse_args() | 3222 (opts, args) = parser.parse_args() |
| 3160 | 3223 |
| 3161 try: | 3224 try: |
| 3162 if not opts.command: | 3225 if not opts.command: |
| 3163 raise RuntimeError('missing required parameter: --command') | 3226 raise RuntimeError('missing required parameter: --command') |
| 3164 | 3227 |
| 3165 if not opts.good_revision: | 3228 if not opts.good_revision: |
| 3166 raise RuntimeError('missing required parameter: --good_revision') | 3229 raise RuntimeError('missing required parameter: --good_revision') |
| 3167 | 3230 |
| 3168 if not opts.bad_revision: | 3231 if not opts.bad_revision: |
| 3169 raise RuntimeError('missing required parameter: --bad_revision') | 3232 raise RuntimeError('missing required parameter: --bad_revision') |
| 3170 | 3233 |
| 3171 if not opts.metric: | 3234 if not opts.metric: |
| 3172 raise RuntimeError('missing required parameter: --metric') | 3235 raise RuntimeError('missing required parameter: --metric') |
| 3173 | 3236 |
| 3174 if opts.gs_bucket: | 3237 if opts.gs_bucket: |
| 3175 if not cloud_storage.List(opts.gs_bucket): | 3238 if not cloud_storage.List(opts.gs_bucket): |
| 3176 raise RuntimeError('Invalid Google Storage URL: [%s]', e) | 3239 raise RuntimeError('Invalid Google Storage: gs://%s' % opts.gs_bucket) |
| 3177 | 3240 if not opts.builder_host: |
| 3241 raise RuntimeError('Must specify try server hostname, when ' | |
| 3242 'gs_bucket is used: --builder_host') | |
| 3243 if not opts.builder_port: | |
| 3244 raise RuntimeError('Must specify try server port number, when ' | |
| 3245 'gs_bucket is used: --builder_port') | |
| 3178 if opts.target_platform == 'cros': | 3246 if opts.target_platform == 'cros': |
| 3179 # Run sudo up front to make sure credentials are cached for later. | 3247 # Run sudo up front to make sure credentials are cached for later. |
| 3180 print 'Sudo is required to build cros:' | 3248 print 'Sudo is required to build cros:' |
| 3181 print | 3249 print |
| 3182 RunProcess(['sudo', 'true']) | 3250 RunProcess(['sudo', 'true']) |
| 3183 | 3251 |
| 3184 if not opts.cros_board: | 3252 if not opts.cros_board: |
| 3185 raise RuntimeError('missing required parameter: --cros_board') | 3253 raise RuntimeError('missing required parameter: --cros_board') |
| 3186 | 3254 |
| 3187 if not opts.cros_remote_ip: | 3255 if not opts.cros_remote_ip: |
| (...skipping 84 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 3272 | 3340 |
| 3273 if not source_control: | 3341 if not source_control: |
| 3274 raise RuntimeError("Sorry, only the git workflow is supported at the " | 3342 raise RuntimeError("Sorry, only the git workflow is supported at the " |
| 3275 "moment.") | 3343 "moment.") |
| 3276 | 3344 |
| 3277 # gClient sync seems to fail if you're not in master branch. | 3345 # gClient sync seems to fail if you're not in master branch. |
| 3278 if (not source_control.IsInProperBranch() and | 3346 if (not source_control.IsInProperBranch() and |
| 3279 not opts.debug_ignore_sync and | 3347 not opts.debug_ignore_sync and |
| 3280 not opts.working_directory): | 3348 not opts.working_directory): |
| 3281 raise RuntimeError("You must switch to master branch to run bisection.") | 3349 raise RuntimeError("You must switch to master branch to run bisection.") |
| 3282 | |
| 3283 bisect_test = BisectPerformanceMetrics(source_control, opts) | 3350 bisect_test = BisectPerformanceMetrics(source_control, opts) |
| 3284 try: | 3351 try: |
| 3285 bisect_results = bisect_test.Run(opts.command, | 3352 bisect_results = bisect_test.Run(opts.command, |
| 3286 opts.bad_revision, | 3353 opts.bad_revision, |
| 3287 opts.good_revision, | 3354 opts.good_revision, |
| 3288 opts.metric) | 3355 opts.metric) |
| 3289 if bisect_results['error']: | 3356 if bisect_results['error']: |
| 3290 raise RuntimeError(bisect_results['error']) | 3357 raise RuntimeError(bisect_results['error']) |
| 3291 bisect_test.FormatAndPrintResults(bisect_results) | 3358 bisect_test.FormatAndPrintResults(bisect_results) |
| 3292 return 0 | 3359 return 0 |
| 3293 finally: | 3360 finally: |
| 3294 bisect_test.PerformCleanup() | 3361 bisect_test.PerformCleanup() |
| 3295 except RuntimeError, e: | 3362 except RuntimeError, e: |
| 3296 if opts.output_buildbot_annotations: | 3363 if opts.output_buildbot_annotations: |
| 3297 # The perf dashboard scrapes the "results" step in order to comment on | 3364 # The perf dashboard scrapes the "results" step in order to comment on |
| 3298 # bugs. If you change this, please update the perf dashboard as well. | 3365 # bugs. If you change this, please update the perf dashboard as well. |
| 3299 bisect_utils.OutputAnnotationStepStart('Results') | 3366 bisect_utils.OutputAnnotationStepStart('Results') |
| 3300 print 'Error: %s' % e.message | 3367 print 'Error: %s' % e.message |
| 3301 if opts.output_buildbot_annotations: | 3368 if opts.output_buildbot_annotations: |
| 3302 bisect_utils.OutputAnnotationStepClosed() | 3369 bisect_utils.OutputAnnotationStepClosed() |
| 3303 return 1 | 3370 return 1 |
| 3304 | 3371 |
| 3305 if __name__ == '__main__': | 3372 if __name__ == '__main__': |
| 3306 sys.exit(main()) | 3373 sys.exit(main()) |
| OLD | NEW |