OLD | NEW |
1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
2 # Copyright 2014 The Chromium Authors. All rights reserved. | 2 # Copyright 2014 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 import codecs | 6 import codecs |
7 import copy | 7 import copy |
8 import cStringIO | |
9 import json | |
10 import optparse | 8 import optparse |
11 import os | 9 import os |
12 import pprint | 10 import pprint |
13 import shutil | 11 import shutil |
14 import socket | 12 import socket |
15 import subprocess | 13 import subprocess |
16 import sys | 14 import sys |
17 import time | 15 import time |
18 import urllib2 | |
19 import urlparse | 16 import urlparse |
20 | 17 |
21 import os.path as path | 18 import os.path as path |
22 | 19 |
23 from common import chromium_utils | 20 from common import chromium_utils |
24 | 21 |
25 | 22 |
26 RECOGNIZED_PATHS = { | 23 RECOGNIZED_PATHS = { |
27 # If SVN path matches key, the entire URL is rewritten to the Git url. | 24 # If SVN path matches key, the entire URL is rewritten to the Git url. |
28 '/chrome/trunk/src': | 25 '/chrome/trunk/src': |
(...skipping 83 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
112 THIS_DIR = path.abspath(os.getcwd()) | 109 THIS_DIR = path.abspath(os.getcwd()) |
113 BUILDER_DIR = path.dirname(THIS_DIR) | 110 BUILDER_DIR = path.dirname(THIS_DIR) |
114 SLAVE_DIR = path.dirname(BUILDER_DIR) | 111 SLAVE_DIR = path.dirname(BUILDER_DIR) |
115 CACHE_DIR = path.join(SLAVE_DIR, 'cache_dir') | 112 CACHE_DIR = path.join(SLAVE_DIR, 'cache_dir') |
116 # Because we print CACHE_DIR out into a .gclient file, and then later run | 113 # Because we print CACHE_DIR out into a .gclient file, and then later run |
117 # eval() on it, backslashes need to be escaped, otherwise "E:\b\build" gets | 114 # eval() on it, backslashes need to be escaped, otherwise "E:\b\build" gets |
118 # parsed as "E:[\x08][\x08]uild". | 115 # parsed as "E:[\x08][\x08]uild". |
119 if sys.platform.startswith('win'): | 116 if sys.platform.startswith('win'): |
120 CACHE_DIR = CACHE_DIR.replace('\\', '\\\\') | 117 CACHE_DIR = CACHE_DIR.replace('\\', '\\\\') |
121 | 118 |
122 # Find the patch tool. | |
123 ROOT_BUILD_DIR = path.dirname(SLAVE_DIR) | |
124 ROOT_B_DIR = path.dirname(ROOT_BUILD_DIR) | |
125 BUILD_INTERNAL_DIR = path.join(ROOT_B_DIR, 'build_internal') | |
126 if sys.platform.startswith('win'): | |
127 PATCH_TOOL = path.join(BUILD_INTERNAL_DIR, 'tools', 'patch.EXE') | |
128 else: | |
129 PATCH_TOOL = '/usr/bin/patch' | |
130 | |
131 | 119 |
132 class SubprocessFailed(Exception): | 120 class SubprocessFailed(Exception): |
133 def __init__(self, message, code): | 121 def __init__(self, message, code): |
134 Exception.__init__(self, message) | 122 Exception.__init__(self, message) |
135 self.code = code | 123 self.code = code |
136 | 124 |
137 | 125 |
138 def call(*args, **kwargs): | 126 def call(*args, **kwargs): |
139 """Interactive subprocess call.""" | 127 """Interactive subprocess call.""" |
140 kwargs['stdout'] = subprocess.PIPE | 128 kwargs['stdout'] = subprocess.PIPE |
141 kwargs['stderr'] = subprocess.STDOUT | 129 kwargs['stderr'] = subprocess.STDOUT |
142 stdin_data = kwargs.pop('stdin_data', None) | |
143 if stdin_data: | |
144 kwargs['stdin'] = subprocess.PIPE | |
145 out = cStringIO.StringIO() | |
146 for attempt in xrange(RETRIES): | 130 for attempt in xrange(RETRIES): |
147 attempt_msg = ' (retry #%d)' % attempt if attempt else '' | 131 attempt_msg = ' (retry #%d)' % attempt if attempt else '' |
148 print '===Running %s%s===' % (' '.join(args), attempt_msg) | 132 print '===Running %s%s===' % (' '.join(args), attempt_msg) |
149 start_time = time.time() | 133 start_time = time.time() |
150 proc = subprocess.Popen(args, **kwargs) | 134 proc = subprocess.Popen(args, **kwargs) |
151 if stdin_data: | |
152 proc.stdin.write(stdin_data) | |
153 proc.stdin.close() | |
154 # This is here because passing 'sys.stdout' into stdout for proc will | 135 # This is here because passing 'sys.stdout' into stdout for proc will |
155 # produce out of order output. | 136 # produce out of order output. |
156 while True: | 137 while True: |
157 buf = proc.stdout.read(1) | 138 buf = proc.stdout.read(1) |
158 if not buf: | 139 if not buf: |
159 break | 140 break |
160 sys.stdout.write(buf) | 141 sys.stdout.write(buf) |
161 out.write(buf) | |
162 code = proc.wait() | 142 code = proc.wait() |
163 elapsed_time = ((time.time() - start_time) / 60.0) | 143 elapsed_time = ((time.time() - start_time) / 60.0) |
164 if not code: | 144 if not code: |
165 print '===Succeeded in %.1f mins===' % elapsed_time | 145 print '===Succeeded in %.1f mins===' % elapsed_time |
166 print | 146 print |
167 return out.getvalue() | 147 return 0 |
168 print '===Failed in %.1f mins===' % elapsed_time | 148 print '===Failed in %.1f mins===' % elapsed_time |
169 print | 149 print |
170 | 150 |
171 raise SubprocessFailed('%s failed with code %d in %s after %d attempts.' % | 151 raise SubprocessFailed('%s failed with code %d in %s after %d attempts.' % |
172 (' '.join(args), code, os.getcwd(), RETRIES), code) | 152 (' '.join(args), code, os.getcwd(), RETRIES), code) |
173 | 153 |
174 | 154 |
175 def git(*args, **kwargs): | 155 def git(*args, **kwargs): |
176 """Wrapper around call specifically for Git commands.""" | 156 """Wrapper around call specifically for Git commands.""" |
177 git_executable = 'git' | 157 git_executable = 'git' |
178 # On windows, subprocess doesn't fuzzy-match 'git' to 'git.bat', so we | 158 # On windows, subprocess doesn't fuzzy-match 'git' to 'git.bat', so we |
179 # have to do it explicitly. This is better than passing shell=True. | 159 # have to do it explicitly. This is better than passing shell=True. |
180 if sys.platform.startswith('win'): | 160 if sys.platform.startswith('win'): |
181 git_executable += '.bat' | 161 git_executable += '.bat' |
182 cmd = (git_executable,) + args | 162 cmd = (git_executable,) + args |
183 return call(*cmd, **kwargs) | 163 call(*cmd, **kwargs) |
184 | 164 |
185 | 165 |
186 def get_gclient_spec(solutions): | 166 def get_gclient_spec(solutions): |
187 return GCLIENT_TEMPLATE % { | 167 return GCLIENT_TEMPLATE % { |
188 'solutions': pprint.pformat(solutions, indent=4), | 168 'solutions': pprint.pformat(solutions, indent=4), |
189 'cache_dir': '"%s"' % CACHE_DIR | 169 'cache_dir': '"%s"' % CACHE_DIR |
190 } | 170 } |
191 | 171 |
192 | 172 |
193 def check_enabled(master, builder, slave): | 173 def check_enabled(master, builder, slave): |
(...skipping 103 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
297 with codecs.open('.gclient', mode='w', encoding='utf-8') as f: | 277 with codecs.open('.gclient', mode='w', encoding='utf-8') as f: |
298 f.write(get_gclient_spec(solutions)) | 278 f.write(get_gclient_spec(solutions)) |
299 | 279 |
300 | 280 |
301 def gclient_sync(): | 281 def gclient_sync(): |
302 gclient_bin = 'gclient.bat' if sys.platform.startswith('win') else 'gclient' | 282 gclient_bin = 'gclient.bat' if sys.platform.startswith('win') else 'gclient' |
303 call(gclient_bin, 'sync', '--verbose', '--reset', '--force', | 283 call(gclient_bin, 'sync', '--verbose', '--reset', '--force', |
304 '--nohooks', '--noprehooks') | 284 '--nohooks', '--noprehooks') |
305 | 285 |
306 | 286 |
307 def create_less_than_or_equal_regex(number): | |
308 """ Return a regular expression to test whether an integer less than or equal | |
309 to 'number' is present in a given string. | |
310 """ | |
311 | |
312 # In three parts, build a regular expression that match any numbers smaller | |
313 # than 'number'. | |
314 # For example, 78656 would give a regular expression that looks like: | |
315 # Part 1 | |
316 # (78356| # 78356 | |
317 # Part 2 | |
318 # 7835[0-5]| # 78350-78355 | |
319 # 783[0-4][0-9]| # 78300-78349 | |
320 # 78[0-2][0-9]{2}| # 78000-78299 | |
321 # 7[0-7][0-9]{3}| # 70000-77999 | |
322 # [0-6][0-9]{4}| # 10000-69999 | |
323 # Part 3 | |
324 # [0-9]{1,4} # 0-9999 | |
325 | |
326 # Part 1: Create an array with all the regexes, as described above. | |
327 # Prepopulate it with the number itself. | |
328 number = str(number) | |
329 expressions = [number] | |
330 | |
331 # Convert the number to a list, so we can translate digits in it to | |
332 # expressions. | |
333 num_list = list(number) | |
334 num_len = len(num_list) | |
335 | |
336 # Part 2: Go through all the digits in the number, starting from the end. | |
337 # Each iteration appends a line to 'expressions'. | |
338 for index in range (num_len - 1, -1, -1): | |
339 # Convert this digit back to an integer. | |
340 digit = int(num_list[index]) | |
341 | |
342 # Part 2.1: No processing if this digit is a zero. | |
343 if digit == 0: | |
344 continue | |
345 | |
346 # Part 2.2: We switch the current digit X by a range "[0-(X-1)]". | |
347 if digit == 1: | |
348 num_list[index] = '0' | |
349 else: | |
350 num_list[index] = '[0-%d]' % (digit - 1) | |
351 | |
352 # Part 2.3: We set all following digits to be "[0-9]". | |
353 # Since we just decrementented a digit in a most important position, all | |
354 # following digits don't matter. The possible numbers will always be smaller | |
355 # than before we decremented. | |
356 if (index + 1) < num_len: | |
357 if (num_len - (index + 1)) == 1: | |
358 num_list[index + 1] = '[0-9]' | |
359 else: | |
360 num_list[index + 1] = '[0-9]{%s}' % (num_len - (index + 1)) | |
361 | |
362 # Part 2.4: Add this new sub-expression to the list. | |
363 expressions.append(''.join(num_list[:min(index+2, num_len)])) | |
364 | |
365 # Part 3: We add all the full ranges to match all numbers that are at least | |
366 # one order of magnitude smaller than the original numbers. | |
367 if num_len == 2: | |
368 expressions.append('[0-9]') | |
369 elif num_len > 2: | |
370 expressions.append('[0-9]{1,%s}' % (num_len - 1)) | |
371 | |
372 # All done. We now have our final regular expression. | |
373 regex = '(%s)' % ('|'.join(expressions)) | |
374 return regex | |
375 | |
376 | |
377 def get_git_hash(revision, dir_name): | 287 def get_git_hash(revision, dir_name): |
378 match = "^git-svn-id: [^ ]*@%s " % create_less_than_or_equal_regex(revision) | 288 match = "^git-svn-id: [^ ]*@%d" % revision |
379 cmd = ['git', 'log', '-E', '--grep', match, '--format=%H', '--max-count=1'] | 289 cmd = ['git', 'log', '--grep', match, '--format=%H', dir_name] |
380 results = call(*cmd, cwd=dir_name).strip().splitlines() | 290 return subprocess.check_output(cmd).strip() or None |
381 if results: | |
382 return results[0] | |
383 raise Exception('We can\'t resolve svn revision %s into a git hash' % | |
384 revision) | |
385 | 291 |
386 | 292 |
387 def deps2git(sln_dirs): | 293 def deps2git(sln_dirs): |
388 for sln_dir in sln_dirs: | 294 for sln_dir in sln_dirs: |
389 deps_file = path.join(os.getcwd(), sln_dir, 'DEPS') | 295 deps_file = path.join(os.getcwd(), sln_dir, 'DEPS') |
390 deps_git_file = path.join(os.getcwd(), sln_dir, '.DEPS.git') | 296 deps_git_file = path.join(os.getcwd(), sln_dir, '.DEPS.git') |
391 if not path.isfile(deps_file): | 297 if not path.isfile(deps_file): |
392 return | 298 return |
393 # Do we have a better way of doing this....? | 299 # Do we have a better way of doing this....? |
394 repo_type = 'internal' if 'internal' in sln_dir else 'public' | 300 repo_type = 'internal' if 'internal' in sln_dir else 'public' |
395 call(sys.executable, DEPS2GIT_PATH, '-t', repo_type, | 301 call(sys.executable, DEPS2GIT_PATH, '-t', repo_type, |
396 '--cache_dir=%s' % CACHE_DIR, | 302 '--cache_dir=%s' % CACHE_DIR, |
397 '--deps=%s' % deps_file, '--out=%s' % deps_git_file) | 303 '--deps=%s' % deps_file, '--out=%s' % deps_git_file) |
398 | 304 |
399 | 305 |
400 def emit_got_revision(revision): | |
401 print '@@@SET_BUILD_PROPERTY@got_revision@%s@@@' % revision | |
402 | |
403 def git_checkout(solutions, revision): | 306 def git_checkout(solutions, revision): |
404 build_dir = os.getcwd() | 307 build_dir = os.getcwd() |
405 # Revision only applies to the first solution. | 308 # Revision only applies to the first solution. |
406 first_solution = True | 309 first_solution = True |
407 for sln in solutions: | 310 for sln in solutions: |
408 name = sln['name'] | 311 name = sln['name'] |
409 url = sln['url'] | 312 url = sln['url'] |
410 sln_dir = path.join(build_dir, name) | 313 sln_dir = path.join(build_dir, name) |
411 if not path.isdir(sln_dir): | 314 if not path.isdir(sln_dir): |
412 git('clone', url, sln_dir) | 315 git('clone', url, sln_dir) |
413 | 316 |
414 # Clean out .DEPS.git changes first. | 317 # Clean out .DEPS.git changes first. |
415 try: | 318 try: |
416 git('reset', '--hard', cwd=sln_dir) | 319 git('reset', '--hard', cwd=sln_dir) |
417 except SubprocessFailed as e: | 320 except SubprocessFailed as e: |
418 if e.code == 128: | 321 if e.code == 128: |
419 # Exited abnormally, theres probably something wrong with the checkout. | 322 # Exited abnormally, theres probably something wrong with the checkout. |
420 # Lets wipe the checkout and try again. | 323 # Lets wipe the checkout and try again. |
421 chromium_utils.RemoveDirectory(sln_dir) | 324 chromium_utils.RemoveDirectory(sln_dir) |
422 git('clone', url, sln_dir) | 325 git('clone', url, sln_dir) |
423 git('reset', '--hard', cwd=sln_dir) | 326 git('reset', '--hard', cwd=sln_dir) |
424 else: | 327 else: |
425 raise | 328 raise |
426 | 329 |
427 git('clean', '-df', cwd=sln_dir) | 330 git('clean', '-df', cwd=sln_dir) |
428 git('pull', 'origin', 'master', cwd=sln_dir) | 331 git('pull', 'origin', 'master', cwd=sln_dir) |
429 # TODO(hinoka): We probably have to make use of revision mapping. | 332 # TODO(hinoka): We probably have to make use of revision mapping. |
430 if first_solution and revision and revision.lower() != 'head': | 333 if first_solution and revision and revision.lower() != 'head': |
431 emit_got_revision(revision) | |
432 if revision and revision.isdigit() and len(revision) < 40: | 334 if revision and revision.isdigit() and len(revision) < 40: |
433 # rev_num is really a svn revision number, convert it into a git hash. | 335 # rev_num is really a svn revision number, convert it into a git hash. |
434 git_ref = get_git_hash(int(revision), name) | 336 git_ref = get_git_hash(revision, name) |
435 else: | 337 else: |
436 # rev_num is actually a git hash or ref, we can just use it. | 338 # rev_num is actually a git hash or ref, we can just use it. |
437 git_ref = revision | 339 git_ref = revision |
438 git('checkout', git_ref, cwd=sln_dir) | 340 git('checkout', git_ref, cwd=sln_dir) |
439 else: | 341 else: |
440 git('checkout', 'origin/master', cwd=sln_dir) | 342 git('checkout', 'origin/master', cwd=sln_dir) |
441 if first_solution: | |
442 git_ref = git('log', '--format=%H', '--max-count=1', | |
443 cwd=sln_dir).strip() | |
444 | 343 |
445 first_solution = False | 344 first_solution = False |
446 return git_ref | |
447 | 345 |
448 | 346 |
449 def _download(url): | 347 def apply_issue(issue, patchset, root, server): |
450 """Fetch url and return content, with retries for flake.""" | 348 pass |
451 for attempt in xrange(RETRIES): | |
452 try: | |
453 return urllib2.urlopen(url).read() | |
454 except Exception: | |
455 if attempt == RETRIES - 1: | |
456 raise | |
457 | |
458 | |
459 def apply_issue_svn(root, patch_url): | |
460 patch_data = call('svn', 'cat', patch_url) | |
461 call(PATCH_TOOL, '-p0', '--remove-empty-files', '--force', '--forward', | |
462 stdin_data=patch_data, cwd=root) | |
463 | |
464 | |
465 def apply_issue_rietveld(issue, patchset, root, server, rev_map, revision): | |
466 apply_issue_bin = ('apply_issue.bat' if sys.platform.startswith('win') | |
467 else 'apply_issue') | |
468 rev_map = json.loads(rev_map) | |
469 if root in rev_map and rev_map[root] == 'got_revision': | |
470 rev_map[root] = revision | |
471 call(apply_issue_bin, | |
472 '--root_dir', root, | |
473 '--issue', issue, | |
474 '--patchset', patchset, | |
475 '--no-auth', | |
476 '--server', server, | |
477 '--revision-mapping', json.dumps(rev_map), | |
478 '--base_ref', revision, | |
479 '--force') | |
480 | 349 |
481 | 350 |
482 def check_flag(flag_file): | 351 def check_flag(flag_file): |
483 """Returns True if the flag file is present.""" | 352 """Returns True if the flag file is present.""" |
484 return os.path.isfile(flag_file) | 353 return os.path.isfile(flag_file) |
485 | 354 |
486 | 355 |
487 def delete_flag(flag_file): | 356 def delete_flag(flag_file): |
488 """Remove bot update flag.""" | 357 """Remove bot update flag.""" |
489 if os.path.isfile(flag_file): | 358 if os.path.isfile(flag_file): |
490 os.remove(flag_file) | 359 os.remove(flag_file) |
491 | 360 |
492 | 361 |
493 def emit_flag(flag_file): | 362 def emit_flag(flag_file): |
494 """Deposit a bot update flag on the system to tell gclient not to run.""" | 363 """Deposit a bot update flag on the system to tell gclient not to run.""" |
495 print 'Emitting flag file at %s' % flag_file | 364 print 'Emitting flag file at %s' % flag_file |
496 with open(flag_file, 'wb') as f: | 365 with open(flag_file, 'wb') as f: |
497 f.write('Success!') | 366 f.write('Success!') |
498 | 367 |
499 | 368 |
500 def parse_args(): | 369 def parse_args(): |
501 parse = optparse.OptionParser() | 370 parse = optparse.OptionParser() |
502 | 371 |
503 parse.add_option('--issue', help='Issue number to patch from.') | 372 parse.add_option('--issue', help='Issue number to patch from.') |
504 parse.add_option('--patchset', | 373 parse.add_option('--patchset', |
505 help='Patchset from issue to patch from, if applicable.') | 374 help='Patchset from issue to patch from, if applicable.') |
506 parse.add_option('--patch_url', help='Optional URL to SVN patch.') | 375 parse.add_option('--patch_url', help='Optional URL to SVN patch.') |
507 parse.add_option('--root', help='Repository root.') | 376 parse.add_option('--root', help='Repository root.') |
508 parse.add_option('--rietveld_server', | 377 parse.add_option('--rietveld_server', help='Rietveld server.') |
509 default='codereview.chromium.org', | |
510 help='Rietveld server.') | |
511 parse.add_option('--specs', help='Gcilent spec.') | 378 parse.add_option('--specs', help='Gcilent spec.') |
512 parse.add_option('--master', help='Master name.') | 379 parse.add_option('--master', help='Master name.') |
513 parse.add_option('-f', '--force', action='store_true', | 380 parse.add_option('-f', '--force', action='store_true', |
514 help='Bypass check to see if we want to be run. ' | 381 help='Bypass check to see if we want to be run. ' |
515 'Should ONLY be used locally.') | 382 'Should ONLY be used locally.') |
516 parse.add_option('--revision_mapping') | 383 # TODO(hinoka): We don't actually use this yet, we should factor this in. |
| 384 parse.add_option('--revision-mapping') |
517 parse.add_option('--revision') | 385 parse.add_option('--revision') |
518 parse.add_option('--slave_name', default=socket.getfqdn().split('.')[0], | 386 parse.add_option('--slave_name', default=socket.getfqdn().split('.')[0], |
519 help='Hostname of the current machine, ' | 387 help='Hostname of the current machine, ' |
520 'used for determining whether or not to activate.') | 388 'used for determining whether or not to activate.') |
521 parse.add_option('--builder_name', help='Name of the builder, ' | 389 parse.add_option('--builder_name', help='Name of the builder, ' |
522 'used for determining whether or not to activate.') | 390 'used for determining whether or not to activate.') |
523 parse.add_option('--build_dir', default=os.getcwd()) | 391 parse.add_option('--build_dir', default=os.getcwd()) |
524 parse.add_option('--flag_file', default=path.join(os.getcwd(), | 392 parse.add_option('--flag_file', default=path.join(os.getcwd(), |
525 'update.flag')) | 393 'update.flag')) |
526 | 394 |
(...skipping 35 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
562 ensure_no_checkout(dir_names, '.svn') | 430 ensure_no_checkout(dir_names, '.svn') |
563 emit_flag(options.flag_file) | 431 emit_flag(options.flag_file) |
564 else: | 432 else: |
565 delete_flag(options.flag_file) | 433 delete_flag(options.flag_file) |
566 return | 434 return |
567 | 435 |
568 # Get a checkout of each solution, without DEPS or hooks. | 436 # Get a checkout of each solution, without DEPS or hooks. |
569 # Calling git directory because there is no way to run Gclient without | 437 # Calling git directory because there is no way to run Gclient without |
570 # invoking DEPS. | 438 # invoking DEPS. |
571 print 'Fetching Git checkout' | 439 print 'Fetching Git checkout' |
572 got_revision = git_checkout(git_solutions, options.revision) | 440 git_checkout(git_solutions, options.revision) |
573 | 441 |
574 options.root = options.root or dir_names[0] | 442 # TODO(hinoka): This must be implemented before we can turn this on for TS. |
575 if options.patch_url: | 443 # if options.issue: |
576 apply_issue_svn(options.root, options.patch_url) | 444 # apply_issue(options.issue, options.patchset, options.root, options.server) |
577 elif options.issue: | |
578 apply_issue_rietveld(options.issue, options.patchset, options.root, | |
579 options.rietveld_server, options.revision_mapping, | |
580 got_revision) | |
581 | 445 |
582 # Magic to get deps2git to work with internal DEPS. | 446 # Magic to get deps2git to work with internal DEPS. |
583 shutil.copyfile(S2G_INTERNAL_FROM_PATH, S2G_INTERNAL_DEST_PATH) | 447 shutil.copyfile(S2G_INTERNAL_FROM_PATH, S2G_INTERNAL_DEST_PATH) |
584 deps2git(dir_names) | 448 deps2git(dir_names) |
585 | 449 |
586 gclient_configure(git_solutions) | 450 gclient_configure(git_solutions) |
587 gclient_sync() | 451 gclient_sync() |
588 | 452 |
589 | 453 |
590 if __name__ == '__main__': | 454 if __name__ == '__main__': |
591 sys.exit(main()) | 455 sys.exit(main()) |
OLD | NEW |