OLD | NEW |
---|---|
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2011 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 """Snapshot Build Bisect Tool | 6 """Snapshot Build Bisect Tool |
7 | 7 |
8 This script bisects a snapshot archive using binary search. It starts at | 8 This script bisects a snapshot archive using binary search. It starts at |
9 a bad revision (it will try to guess HEAD) and asks for a last known-good | 9 a bad revision (it will try to guess HEAD) and asks for a last known-good |
10 revision. It will then binary search across this revision range by downloading, | 10 revision. It will then binary search across this revision range by downloading, |
11 unzipping, and opening Chromium for you. After testing the specific revision, | 11 unzipping, and opening Chromium for you. After testing the specific revision, |
12 it will ask you whether it is good or bad before continuing the search. | 12 it will ask you whether it is good or bad before continuing the search. |
13 """ | 13 """ |
14 | 14 |
15 # The root URL for storage. | 15 # The root URL for storage. |
16 BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-snapshots' | 16 BASE_URL = 'http://commondatastorage.googleapis.com/chromium-browser-snapshots' |
17 BASE_URL_RECENT = 'http://build.chromium.org/f/chromium/snapshots' | |
17 | 18 |
18 # URL to the ViewVC commit page. | 19 # URL to the ViewVC commit page. |
19 BUILD_VIEWVC_URL = 'http://src.chromium.org/viewvc/chrome?view=rev&revision=%d' | 20 BUILD_VIEWVC_URL = 'http://src.chromium.org/viewvc/chrome?view=rev&revision=%d' |
20 | 21 |
21 # Changelogs URL. | 22 # Changelogs URL. |
22 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \ | 23 CHANGELOG_URL = 'http://build.chromium.org/f/chromium/' \ |
23 'perf/dashboard/ui/changelog.html?url=/trunk/src&range=%d:%d' | 24 'perf/dashboard/ui/changelog.html?url=/trunk/src&range=%d:%d' |
24 | 25 |
26 # For BASE_URL_RECENT, the string prefix before each revision number. | |
27 RECENT_REV_HTML_PREFIX = \ | |
28 '<img src="/icons/folder.gif" alt="[DIR]"></td><td><a href="' | |
29 | |
25 ############################################################################### | 30 ############################################################################### |
26 | 31 |
27 import math | 32 import math |
28 import optparse | 33 import optparse |
29 import os | 34 import os |
30 import pipes | 35 import pipes |
31 import re | 36 import re |
32 import shutil | 37 import shutil |
33 import sys | 38 import sys |
34 import tempfile | 39 import tempfile |
35 import urllib | 40 import urllib |
36 from xml.etree import ElementTree | 41 from xml.etree import ElementTree |
37 import zipfile | 42 import zipfile |
38 | 43 |
39 class PathContext(object): | 44 class PathContext(object): |
40 """A PathContext is used to carry the information used to construct URLs and | 45 """A PathContext is used to carry the information used to construct URLs and |
41 paths when dealing with the storage server and archives.""" | 46 paths when dealing with the storage server and archives.""" |
42 def __init__(self, platform, good_revision, bad_revision): | 47 def __init__(self, platform, good_revision, bad_revision, is_recent): |
Robert Sesek
2011/07/20 19:05:11
|is_recent| sounds like it's describing the contex
jbates
2011/07/20 21:08:42
Done.
| |
43 super(PathContext, self).__init__() | 48 super(PathContext, self).__init__() |
44 # Store off the input parameters. | 49 # Store off the input parameters. |
45 self.platform = platform # What's passed in to the '-a/--archive' option. | 50 self.platform = platform # What's passed in to the '-a/--archive' option. |
46 self.good_revision = good_revision | 51 self.good_revision = good_revision |
47 self.bad_revision = bad_revision | 52 self.bad_revision = bad_revision |
53 self.is_recent = is_recent | |
48 | 54 |
49 # The name of the ZIP file in a revision directory on the server. | 55 # The name of the ZIP file in a revision directory on the server. |
50 self.archive_name = None | 56 self.archive_name = None |
51 | 57 |
52 # Set some internal members: | 58 # Set some internal members: |
53 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'. | 59 # _listing_platform_dir = Directory that holds revisions. Ends with a '/'. |
54 # _archive_extract_dir = Uncompressed directory in the archive_name file. | 60 # _archive_extract_dir = Uncompressed directory in the archive_name file. |
55 # _binary_name = The name of the executable to run. | 61 # _binary_name = The name of the executable to run. |
56 if self.platform == 'linux' or self.platform == 'linux64': | 62 if self.platform == 'linux' or self.platform == 'linux64': |
57 self._listing_platform_dir = 'Linux/' | 63 self._listing_platform_dir = 'Linux/' |
(...skipping 17 matching lines...) Expand all Loading... | |
75 raise Exception("Invalid platform") | 81 raise Exception("Invalid platform") |
76 | 82 |
77 def GetListingURL(self, marker=None): | 83 def GetListingURL(self, marker=None): |
78 """Returns the URL for a directory listing, with an optional marker.""" | 84 """Returns the URL for a directory listing, with an optional marker.""" |
79 marker_param = '' | 85 marker_param = '' |
80 if marker: | 86 if marker: |
81 marker_param = '&marker=' + str(marker) | 87 marker_param = '&marker=' + str(marker) |
82 return BASE_URL + '/?delimiter=/&prefix=' + self._listing_platform_dir + \ | 88 return BASE_URL + '/?delimiter=/&prefix=' + self._listing_platform_dir + \ |
83 marker_param | 89 marker_param |
84 | 90 |
91 def GetListingURLRecent(self): | |
92 """Returns the URL for a directory listing of recent builds.""" | |
93 return BASE_URL_RECENT + '/' + self._listing_platform_dir | |
94 | |
85 def GetDownloadURL(self, revision): | 95 def GetDownloadURL(self, revision): |
86 """Gets the download URL for a build archive of a specific revision.""" | 96 """Gets the download URL for a build archive of a specific revision.""" |
87 return "%s/%s%d/%s" % ( | 97 if self.is_recent: |
88 BASE_URL, self._listing_platform_dir, revision, self.archive_name) | 98 return "%s/%s%d/%s" % ( |
99 BASE_URL_RECENT, self._listing_platform_dir, revision, | |
100 self.archive_name) | |
101 else: | |
102 return "%s/%s%d/%s" % ( | |
103 BASE_URL, self._listing_platform_dir, revision, self.archive_name) | |
89 | 104 |
90 def GetLastChangeURL(self): | 105 def GetLastChangeURL(self): |
91 """Returns a URL to the LAST_CHANGE file.""" | 106 """Returns a URL to the LAST_CHANGE file.""" |
92 return BASE_URL + '/' + self._listing_platform_dir + 'LAST_CHANGE' | 107 return BASE_URL + '/' + self._listing_platform_dir + 'LAST_CHANGE' |
93 | 108 |
94 def GetLaunchPath(self): | 109 def GetLaunchPath(self): |
95 """Returns a relative path (presumably from the archive extraction location) | 110 """Returns a relative path (presumably from the archive extraction location) |
96 that is used to run the executable.""" | 111 that is used to run the executable.""" |
97 return os.path.join(self._archive_extract_dir, self._binary_name) | 112 return os.path.join(self._archive_extract_dir, self._binary_name) |
98 | 113 |
(...skipping 76 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
175 (revisions, next_marker) = _FetchAndParse(context.GetListingURL()) | 190 (revisions, next_marker) = _FetchAndParse(context.GetListingURL()) |
176 # If the result list was truncated, refetch with the next marker. Do this | 191 # If the result list was truncated, refetch with the next marker. Do this |
177 # until an entire directory listing is done. | 192 # until an entire directory listing is done. |
178 while next_marker: | 193 while next_marker: |
179 (new_revisions, next_marker) = _FetchAndParse( | 194 (new_revisions, next_marker) = _FetchAndParse( |
180 context.GetListingURL(next_marker)) | 195 context.GetListingURL(next_marker)) |
181 revisions.extend(new_revisions) | 196 revisions.extend(new_revisions) |
182 | 197 |
183 return revisions | 198 return revisions |
184 | 199 |
200 def ParseDirectoryIndexRecent(context): | |
201 """Parses the recent builds directory listing into a list of revision | |
202 numbers.""" | |
203 revisions = [] | |
204 handle = urllib.urlopen(context.GetListingURLRecent()) | |
205 document = handle.read() | |
206 | |
207 index = document.find(RECENT_REV_HTML_PREFIX) | |
Robert Sesek
2011/07/20 19:05:11
Why not use a regex to do this? Use re.compile for
jbates
2011/07/20 21:08:42
Done.
| |
208 while index > 0: | |
209 index += len(RECENT_REV_HTML_PREFIX) | |
210 end_slash_index = document.find('/', index) | |
211 rev = int(document[index: end_slash_index]) | |
212 revisions.append(rev) | |
213 index = document.find(RECENT_REV_HTML_PREFIX, index) | |
214 | |
215 return revisions | |
216 | |
185 | 217 |
186 def GetRevList(context): | 218 def GetRevList(context): |
187 """Gets the list of revision numbers between |good_revision| and | 219 """Gets the list of revision numbers between |good_revision| and |
188 |bad_revision| of the |context|.""" | 220 |bad_revision| of the |context|.""" |
189 # Download the revlist and filter for just the range between good and bad. | 221 # Download the revlist and filter for just the range between good and bad. |
190 rev_range = range(context.good_revision, context.bad_revision) | 222 rev_range = range(context.good_revision, context.bad_revision) |
191 revlist = map(int, ParseDirectoryIndex(context)) | 223 revisions = [] |
224 if context.is_recent: | |
225 revisions = ParseDirectoryIndexRecent(context) | |
226 else: | |
227 revisions = ParseDirectoryIndex(context) | |
228 revlist = map(int, revisions) | |
192 revlist = filter(lambda r: r in rev_range, revlist) | 229 revlist = filter(lambda r: r in rev_range, revlist) |
193 revlist.sort() | 230 revlist.sort() |
194 return revlist | 231 return revlist |
195 | 232 |
196 | 233 |
197 def TryRevision(context, rev, profile, args): | 234 def TryRevision(context, rev, profile, args): |
198 """Downloads revision |rev|, unzips it, and opens it for the user to test. | 235 """Downloads revision |rev|, unzips it, and opens it for the user to test. |
199 |profile| is the profile to use.""" | 236 |profile| is the profile to use.""" |
200 # Do this in a temp dir so we don't collide with user files. | 237 # Do this in a temp dir so we don't collide with user files. |
201 cwd = os.getcwd() | 238 cwd = os.getcwd() |
(...skipping 10 matching lines...) Expand all Loading... | |
212 size = min(totalsize, size) | 249 size = min(totalsize, size) |
213 progress = "Received %d of %d bytes, %.2f%%" % ( | 250 progress = "Received %d of %d bytes, %.2f%%" % ( |
214 size, totalsize, 100.0 * size / totalsize) | 251 size, totalsize, 100.0 * size / totalsize) |
215 # Send a \r to let all progress messages use just one line of output. | 252 # Send a \r to let all progress messages use just one line of output. |
216 sys.stdout.write("\r" + progress) | 253 sys.stdout.write("\r" + progress) |
217 sys.stdout.flush() | 254 sys.stdout.flush() |
218 try: | 255 try: |
219 print 'Fetching ' + download_url | 256 print 'Fetching ' + download_url |
220 urllib.urlretrieve(download_url, context.archive_name, _ReportHook) | 257 urllib.urlretrieve(download_url, context.archive_name, _ReportHook) |
221 print | 258 print |
259 # Throw an exception if the download was less than 1000 bytes. | |
260 if os.path.getsize(context.archive_name) < 1000: raise Exception() | |
222 except Exception, e: | 261 except Exception, e: |
223 print('Could not retrieve the download. Sorry.') | 262 print('Could not retrieve the download. Sorry.') |
224 sys.exit(-1) | 263 sys.exit(-1) |
225 | 264 |
226 # Unzip the file. | 265 # Unzip the file. |
227 print 'Unzipping ...' | 266 print 'Unzipping ...' |
228 UnzipFilenameToDir(context.archive_name, os.curdir) | 267 UnzipFilenameToDir(context.archive_name, os.curdir) |
229 | 268 |
230 # Tell the system to open the app. | 269 # Tell the system to open the app. |
231 args = ['--user-data-dir=%s' % profile] + args | 270 args = ['--user-data-dir=%s' % profile] + args |
(...skipping 12 matching lines...) Expand all Loading... | |
244 | 283 |
245 def AskIsGoodBuild(rev): | 284 def AskIsGoodBuild(rev): |
246 """Ask the user whether build |rev| is good or bad.""" | 285 """Ask the user whether build |rev| is good or bad.""" |
247 # Loop until we get a response that we can parse. | 286 # Loop until we get a response that we can parse. |
248 while True: | 287 while True: |
249 response = raw_input('\nBuild %d is [(g)ood/(b)ad]: ' % int(rev)) | 288 response = raw_input('\nBuild %d is [(g)ood/(b)ad]: ' % int(rev)) |
250 if response and response in ('g', 'b'): | 289 if response and response in ('g', 'b'): |
251 return response == 'g' | 290 return response == 'g' |
252 | 291 |
253 | 292 |
254 def Bisect(good, | 293 def Bisect(revlist, |
255 bad, | |
256 revlist, | |
257 context, | 294 context, |
258 try_args=(), | 295 try_args=(), |
259 profile='profile', | 296 profile='profile', |
260 predicate=AskIsGoodBuild): | 297 predicate=AskIsGoodBuild): |
261 """Tries to find the exact commit where a regression was introduced by | 298 """Tries to find the exact commit where a regression was introduced by |
262 running a binary search on all archived builds in a given revision range. | 299 running a binary search on all archived builds in a given revision range. |
263 | 300 |
264 @param good The index in revlist of the last known good revision. | |
265 @param bad The index in revlist of the first known bad revision. | |
266 @param revlist A list of chromium revision numbers to check. | 301 @param revlist A list of chromium revision numbers to check. |
267 @param context A PathContext object. | 302 @param context A PathContext object. |
268 @param try_args A tuple of arguments to pass to the predicate function. | 303 @param try_args A tuple of arguments to pass to the predicate function. |
269 @param profile The user profile with which to run chromium. | 304 @param profile The user profile with which to run chromium. |
270 @param predicate A predicate function which returns True iff the argument | 305 @param predicate A predicate function which returns True iff the argument |
271 chromium revision is good. | 306 chromium revision is good. |
272 """ | 307 """ |
273 | 308 |
309 good = 0 | |
310 bad = len(revlist) - 1 | |
jbates
2011/07/20 00:47:17
Cleanup
| |
274 last_known_good_rev = revlist[good] | 311 last_known_good_rev = revlist[good] |
275 first_known_bad_rev = revlist[bad] | 312 first_known_bad_rev = revlist[bad] |
276 | 313 |
277 # Binary search time! | 314 # Binary search time! |
278 while good < bad: | 315 while good < bad: |
279 candidates = revlist[good:bad] | 316 candidates = revlist[good:bad] |
280 num_poss = len(candidates) | 317 num_poss = len(candidates) |
281 if num_poss > 10: | 318 if num_poss > 10: |
282 print('%d candidates. %d tries left.' % | 319 print('%d candidates. %d tries left.' % |
283 (num_poss, round(math.log(num_poss, 2)))) | 320 (num_poss, round(math.log(num_poss, 2)))) |
284 else: | 321 else: |
285 print('Candidates: %s' % revlist[good:bad]) | 322 print('Candidates: %s' % revlist[good:bad]) |
286 | 323 |
287 # Cut the problem in half... | 324 # Cut the problem in half... |
288 test = int((bad - good) / 2) + good | 325 test = int((bad - good) / 2) + good |
289 test_rev = revlist[test] | 326 test_rev = revlist[test] |
290 | 327 |
291 # Let the user give this rev a spin (in her own profile, if she wants). | 328 # Let the user give this rev a spin (in her own profile, if she wants). |
292 TryRevision(context, test_rev, profile, try_args) | 329 TryRevision(context, test_rev, profile, try_args) |
293 if predicate(test_rev): | 330 if predicate(test_rev): |
294 last_known_good_rev = revlist[good] | 331 last_known_good_rev = test_rev |
jbates
2011/07/20 00:47:17
This fixes a bug that sometimes caused the reporte
Robert Sesek
2011/07/20 19:05:11
Looks like this fixes http://code.google.com/p/chr
jbates
2011/07/20 21:08:42
Done.
| |
295 good = test + 1 | 332 good = test + 1 |
296 else: | 333 else: |
297 bad = test | 334 bad = test |
298 | 335 |
299 return (last_known_good_rev, first_known_bad_rev) | 336 return (last_known_good_rev, first_known_bad_rev) |
300 | 337 |
301 | 338 |
302 def main(): | 339 def main(): |
303 usage = ('%prog [options] [-- chromium-options]\n' | 340 usage = ('%prog [options] [-- chromium-options]\n' |
304 'Perform binary search on the snapshot builds.\n' | 341 'Perform binary search on the snapshot builds.\n' |
305 '\n' | 342 '\n' |
306 'Tip: add "-- --no-first-run" to bypass the first run prompts.') | 343 'Tip: add "-- --no-first-run" to bypass the first run prompts.') |
307 parser = optparse.OptionParser(usage=usage) | 344 parser = optparse.OptionParser(usage=usage) |
308 # Strangely, the default help output doesn't include the choice list. | 345 # Strangely, the default help output doesn't include the choice list. |
309 choices = ['mac', 'win', 'linux', 'linux64'] | 346 choices = ['mac', 'win', 'linux', 'linux64'] |
310 # linux-chromiumos lacks a continuous archive http://crbug.com/78158 | 347 # linux-chromiumos lacks a continuous archive http://crbug.com/78158 |
311 parser.add_option('-a', '--archive', | 348 parser.add_option('-a', '--archive', |
312 choices = choices, | 349 choices = choices, |
313 help = 'The buildbot archive to bisect [%s].' % | 350 help = 'The buildbot archive to bisect [%s].' % |
314 '|'.join(choices)) | 351 '|'.join(choices)) |
315 parser.add_option('-b', '--bad', type = 'int', | 352 parser.add_option('-b', '--bad', type = 'int', |
316 help = 'The bad revision to bisect to.') | 353 help = 'The bad revision to bisect to.') |
317 parser.add_option('-g', '--good', type = 'int', | 354 parser.add_option('-g', '--good', type = 'int', |
318 help = 'The last known good revision to bisect from.') | 355 help = 'The last known good revision to bisect from.') |
319 parser.add_option('-p', '--profile', '--user-data-dir', type = 'str', | 356 parser.add_option('-p', '--profile', '--user-data-dir', type = 'str', |
320 help = 'Profile to use; this will not reset every run. ' + | 357 help = 'Profile to use; this will not reset every run. ' + |
321 'Defaults to a clean profile.', default = 'profile') | 358 'Defaults to a clean profile.', default = 'profile') |
359 parser.add_option('-r', '--recent', | |
360 dest = "recent", | |
361 default = False, | |
362 action = "store_true", | |
363 help = 'Use recent builds from about the last 2 months ' + | |
364 'for higher granularity bisecting.') | |
365 parser.add_option('-h', '--help', | |
Robert Sesek
2011/07/20 19:05:11
I think this is done for you automatically by the
jbates
2011/07/20 21:08:42
Done.
| |
366 dest = "help", | |
367 default = False, | |
368 action = "store_true", | |
369 help = 'Show usage.') | |
322 (opts, args) = parser.parse_args() | 370 (opts, args) = parser.parse_args() |
323 | 371 |
372 if opts.help: | |
373 parser.print_help() | |
374 return 0 | |
375 | |
324 if opts.archive is None: | 376 if opts.archive is None: |
325 print 'Error: missing required parameter: --archive' | 377 print 'Error: missing required parameter: --archive' |
326 print | 378 print |
327 parser.print_help() | 379 parser.print_help() |
328 return 1 | 380 return 1 |
329 | 381 |
330 if opts.bad and opts.good and (opts.good > opts.bad): | 382 if opts.bad and opts.good and (opts.good > opts.bad): |
331 print ('The good revision (%d) must precede the bad revision (%d).\n' % | 383 print ('The good revision (%d) must precede the bad revision (%d).\n' % |
332 (opts.good, opts.bad)) | 384 (opts.good, opts.bad)) |
333 parser.print_help() | 385 parser.print_help() |
334 return 1 | 386 return 1 |
335 | 387 |
336 # Create the context. Initialize 0 for the revisions as they are set below. | 388 # Create the context. Initialize 0 for the revisions as they are set below. |
337 context = PathContext(opts.archive, 0, 0) | 389 context = PathContext(opts.archive, 0, 0, False) |
338 | 390 |
339 # Pick a starting point, try to get HEAD for this. | 391 # Pick a starting point, try to get HEAD for this. |
340 if opts.bad: | 392 if opts.bad: |
341 bad_rev = opts.bad | 393 bad_rev = opts.bad |
342 else: | 394 else: |
343 bad_rev = 0 | 395 bad_rev = 0 |
344 try: | 396 try: |
345 # Location of the latest build revision number | 397 # Location of the latest build revision number |
346 nh = urllib.urlopen(context.GetLastChangeURL()) | 398 nh = urllib.urlopen(context.GetLastChangeURL()) |
347 latest = int(nh.read()) | 399 latest = int(nh.read()) |
(...skipping 12 matching lines...) Expand all Loading... | |
360 else: | 412 else: |
361 good_rev = 0 | 413 good_rev = 0 |
362 try: | 414 try: |
363 good_rev = int(raw_input('Last known good [0]: ')) | 415 good_rev = int(raw_input('Last known good [0]: ')) |
364 except Exception, e: | 416 except Exception, e: |
365 pass | 417 pass |
366 | 418 |
367 # Set the input parameters now that they've been validated. | 419 # Set the input parameters now that they've been validated. |
368 context.good_revision = good_rev | 420 context.good_revision = good_rev |
369 context.bad_revision = bad_rev | 421 context.bad_revision = bad_rev |
422 context.is_recent = opts.recent | |
Robert Sesek
2011/07/20 19:05:11
You're not validating this, so why not pass it dir
jbates
2011/07/20 21:08:42
Done.
| |
370 | 423 |
371 # Get a list of revisions to bisect across. | 424 # Get a list of revisions to bisect across. |
372 revlist = GetRevList(context) | 425 revlist = GetRevList(context) |
373 if len(revlist) < 2: # Don't have enough builds to bisect | 426 if len(revlist) < 2: # Don't have enough builds to bisect |
374 print 'We don\'t have enough builds to bisect. revlist: %s' % revlist | 427 print 'We don\'t have enough builds to bisect. revlist: %s' % revlist |
375 sys.exit(1) | 428 sys.exit(1) |
376 | 429 |
377 # If we don't have a |good_rev|, set it to be the first revision possible. | |
378 if good_rev == 0: | |
379 good_rev = revlist[0] | |
jbates
2011/07/20 00:47:17
good_rev is not used anymore, so this was unnecess
| |
380 | |
381 # These are indexes of |revlist|. | |
382 good = 0 | |
383 bad = len(revlist) - 1 | |
jbates
2011/07/20 00:47:17
Moved to inside Bisect -- it already is passed the
| |
384 | |
385 (last_known_good_rev, first_known_bad_rev) = Bisect( | 430 (last_known_good_rev, first_known_bad_rev) = Bisect( |
386 good, bad, revlist, context, args, opts.profile) | 431 revlist, context, args, opts.profile) |
387 | 432 |
388 # We're done. Let the user know the results in an official manner. | 433 # We're done. Let the user know the results in an official manner. |
389 print('You are probably looking for build %d.' % first_known_bad_rev) | 434 print('You are probably looking for build %d.' % first_known_bad_rev) |
390 print('CHANGELOG URL:') | 435 print('CHANGELOG URL:') |
391 print(CHANGELOG_URL % (last_known_good_rev, first_known_bad_rev)) | 436 print(CHANGELOG_URL % (last_known_good_rev, first_known_bad_rev)) |
392 print('Built at revision:') | 437 print('Built at revision:') |
393 print(BUILD_VIEWVC_URL % first_known_bad_rev) | 438 print(BUILD_VIEWVC_URL % first_known_bad_rev) |
394 | 439 |
395 if __name__ == '__main__': | 440 if __name__ == '__main__': |
396 sys.exit(main()) | 441 sys.exit(main()) |
OLD | NEW |