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 import argparse | 6 import argparse |
7 import collections | 7 import collections |
8 import contextlib | 8 import contextlib |
9 import json | 9 import json |
10 import logging | 10 import logging |
11 import os | 11 import os |
12 import platform | 12 import platform |
13 import shutil | 13 import shutil |
14 import socket | 14 import socket |
15 import subprocess | 15 import subprocess |
16 import sys | 16 import sys |
17 import tempfile | 17 import tempfile |
18 | 18 |
19 | 19 |
20 # Install Infra build environment. | 20 # Install Infra build environment. |
21 BUILD_ROOT = os.path.dirname(os.path.dirname(os.path.dirname( | 21 BUILD_ROOT = os.path.dirname(os.path.dirname(os.path.dirname( |
22 os.path.abspath(__file__)))) | 22 os.path.abspath(__file__)))) |
23 sys.path.insert(0, os.path.join(BUILD_ROOT, 'scripts')) | 23 sys.path.insert(0, os.path.join(BUILD_ROOT, 'scripts')) |
24 | 24 |
25 from common import annotator | 25 from common import annotator |
26 from common import chromium_utils | 26 from common import chromium_utils |
27 from common import env | 27 from common import env |
28 from common import master_cfg_utils | 28 from common import master_cfg_utils |
| 29 from slave import gce |
29 | 30 |
30 # Logging instance. | 31 # Logging instance. |
31 LOGGER = logging.getLogger('annotated_run') | 32 LOGGER = logging.getLogger('annotated_run') |
32 | 33 |
| 34 # Return codes used by Butler/Annotee to indicate their failure (as opposed to |
| 35 # a forwarded return code from the underlying process). |
| 36 LOGDOG_ERROR_RETURNCODES = ( |
| 37 # Butler runtime error. |
| 38 250, |
| 39 # Annotee runtime error. |
| 40 251, |
| 41 ) |
| 42 |
| 43 # Whitelist of {master}=>[{builder}|WHITELIST_ALL] whitelisting specific masters |
| 44 # and builders for experimental LogDog/Annotee export. |
| 45 LOGDOG_WHITELIST_MASTER_BUILDERS = { |
| 46 } |
| 47 |
| 48 # Sentinel value that, if present in master config, matches all builders |
| 49 # underneath that master. |
| 50 WHITELIST_ALL = '*' |
| 51 |
| 52 # Configuration for a Pub/Sub topic. |
| 53 PubSubConfig = collections.namedtuple('PubSubConfig', ('project', 'topic')) |
33 | 54 |
34 # RecipeRuntime will probe this for values. | 55 # RecipeRuntime will probe this for values. |
35 # - First, (system, platform) | 56 # - First, (system, platform) |
36 # - Then, (system,) | 57 # - Then, (system,) |
37 # - Finally, (), | 58 # - Finally, (), |
38 PLATFORM_CONFIG = { | 59 PLATFORM_CONFIG = { |
39 # All systems. | 60 # All systems. |
40 (): {}, | 61 (): { |
| 62 'logdog_pubsub': PubSubConfig( |
| 63 project='luci-logdog', |
| 64 topic='chrome-infra-beta', |
| 65 ), |
| 66 }, |
41 | 67 |
42 # Linux | 68 # Linux |
43 ('Linux',): { | 69 ('Linux',): { |
44 'run_cmd': ['/opt/infra-python/run.py'], | 70 'run_cmd': ['/opt/infra-python/run.py'], |
| 71 'credential_paths': ( |
| 72 # XXX: Get this right? |
| 73 '/opt/infra/service_accounts', |
| 74 ), |
| 75 'logdog_butler_streamserver_gen': lambda d: os.path.join(d, 'butler.sock'), |
45 }, | 76 }, |
46 | 77 |
47 # Mac OSX | 78 # Mac OSX |
48 ('Darwin',): { | 79 ('Darwin',): { |
49 'run_cmd': ['/opt/infra-python/run.py'], | 80 'run_cmd': ['/opt/infra-python/run.py'], |
50 }, | 81 }, |
51 | 82 |
52 # Windows | 83 # Windows |
53 ('Windows',): { | 84 ('Windows',): { |
54 'run_cmd': ['C:\\infra-python\\ENV\\Scripts\\python.exe', | 85 'run_cmd': ['C:\\infra-python\\ENV\\Scripts\\python.exe', |
55 'C:\\infra-python\\run.py'], | 86 'C:\\infra-python\\run.py'], |
56 }, | 87 }, |
57 } | 88 } |
58 | 89 |
59 | 90 |
60 # Config is the runtime configuration used by `annotated_run.py` to bootstrap | 91 # Config is the runtime configuration used by `annotated_run.py` to bootstrap |
61 # the recipe engine. | 92 # the recipe engine. |
62 Config = collections.namedtuple('Config', ( | 93 Config = collections.namedtuple('Config', ( |
63 'run_cmd', | 94 'run_cmd', |
| 95 'logdog_pubsub', |
| 96 'logdog_butler_streamserver_gen', |
| 97 'credential_paths', |
64 )) | 98 )) |
65 | 99 |
66 | 100 |
67 def get_config(): | 101 def get_config(): |
68 """Returns (Config): The constructed Config object. | 102 """Returns (Config): The constructed Config object. |
69 | 103 |
70 The Config object is constructed from: | 104 The Config object is constructed from: |
71 - Cascading the PLATFORM_CONFIG fields together based on current | 105 - Cascading the PLATFORM_CONFIG fields together based on current |
72 OS/Architecture. | 106 OS/Architecture. |
73 | 107 |
74 Raises: | 108 Raises: |
75 KeyError: if a required configuration key/parameter is not available. | 109 KeyError: if a required configuration key/parameter is not available. |
76 """ | 110 """ |
77 # Cascade the platform configuration. | 111 # Cascade the platform configuration. |
78 p = (platform.system(), platform.processor()) | 112 p = (platform.system(), platform.processor()) |
79 platform_config = {} | 113 platform_config = {} |
80 for i in xrange(len(p)+1): | 114 for i in xrange(len(p)+1): |
81 platform_config.update(PLATFORM_CONFIG.get(p[:i], {})) | 115 platform_config.update(PLATFORM_CONFIG.get(p[:i], {})) |
82 | 116 |
83 # Construct runtime configuration. | 117 # Construct runtime configuration. |
84 return Config( | 118 return Config( |
85 run_cmd=platform_config.get('run_cmd'), | 119 run_cmd=platform_config.get('run_cmd'), |
| 120 logdog_pubsub=platform_config.get('logdog_pubsub'), |
| 121 logdog_butler_streamserver_gen=platform_config.get( |
| 122 'logdog_butler_streamserver_gen'), |
| 123 credential_paths=platform_config.get('credential_paths', ()), |
86 ) | 124 ) |
87 | 125 |
88 | 126 |
89 def ensure_directory(*path): | 127 def ensure_directory(*path): |
90 path = os.path.join(*path) | 128 path = os.path.join(*path) |
91 if not os.path.isdir(path): | 129 if not os.path.isdir(path): |
92 os.makedirs(path) | 130 os.makedirs(path) |
93 return path | 131 return path |
94 | 132 |
95 | 133 |
(...skipping 41 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
137 LOGGER.debug('Cleaning up temporary directory [%s].', basedir) | 175 LOGGER.debug('Cleaning up temporary directory [%s].', basedir) |
138 try: | 176 try: |
139 chromium_utils.RemoveDirectory(basedir) | 177 chromium_utils.RemoveDirectory(basedir) |
140 except Exception: | 178 except Exception: |
141 LOGGER.exception('Failed to clean up temporary directory [%s].', | 179 LOGGER.exception('Failed to clean up temporary directory [%s].', |
142 basedir) | 180 basedir) |
143 else: | 181 else: |
144 LOGGER.warning('(--leak) Leaking temporary directory [%s].', basedir) | 182 LOGGER.warning('(--leak) Leaking temporary directory [%s].', basedir) |
145 | 183 |
146 | 184 |
| 185 class LogDogNotBootstrapped(Exception): |
| 186 pass |
| 187 |
| 188 |
| 189 class LogDogBootstrapError(Exception): |
| 190 pass |
| 191 |
| 192 |
| 193 def is_executable(path): |
| 194 return os.path.isfile(path) and os.access(path, os.X_OK) |
| 195 |
| 196 |
| 197 def ensure_directory(*path): |
| 198 path = os.path.join(*path) |
| 199 if not os.path.isdir(path): |
| 200 os.makedirs(path) |
| 201 return path |
| 202 |
| 203 |
| 204 def _run_command(cmd, **kwargs): |
| 205 dry_run = kwargs.pop('dry_run', False) |
| 206 |
| 207 LOGGER.debug('Executing command: %s', cmd) |
| 208 if dry_run: |
| 209 LOGGER.info('(Dry Run) Not executing command.') |
| 210 return 0, '' |
| 211 proc = subprocess.Popen(cmd, stderr=subprocess.STDOUT) |
| 212 stdout, _ = proc.communicate() |
| 213 |
| 214 LOGGER.debug('Process [%s] returned [%d] with output:\n%s', |
| 215 cmd, proc.returncode, stdout) |
| 216 return proc.returncode, stdout |
| 217 |
| 218 |
| 219 def _check_command(*args, **kwargs): |
| 220 rv, stdout = _run_command(args, **kwargs) |
| 221 if rv != 0: |
| 222 raise ValueError('Process exited with non-zero return code (%d)' % (rv,)) |
| 223 return stdout |
| 224 |
| 225 |
| 226 class RecipeRuntime(object): |
| 227 """RecipeRuntime is the platform-specific runtime enviornment. |
| 228 |
| 229 The runtime is loaded with a set of read-only attributes that are a |
| 230 combination of plaetform and runtime values used in the setup and execution of |
| 231 the recipe engine. |
| 232 """ |
| 233 |
| 234 _SENTINEL = object() |
| 235 |
| 236 def __init__(self, **kwargs): |
| 237 self._attrs = kwargs |
| 238 |
| 239 @classmethod |
| 240 @contextlib.contextmanager |
| 241 def enter(cls, leak, **kw): |
| 242 """Enters the annotated_run environment. |
| 243 |
| 244 This creates a temporary directory for this annotation run that is |
| 245 automatically cleaned up. It returns a RecipeRuntime object containing a |
| 246 combination of the supplied keyword arguments and the platform-specific |
| 247 configuration. |
| 248 |
| 249 Args: |
| 250 leak (bool): If true, don't clean up the temporary directory on exit. |
| 251 kw (dict): Key/value pairs to add as attributes to the RecipeRuntime. |
| 252 """ |
| 253 # Build our platform attributes. |
| 254 p = (platform.system(), platform.processor()) |
| 255 attrs = {} |
| 256 for i in xrange(len(p)+1): |
| 257 attrs.update(PLATFORM_CONFIG.get(p[:i], {})) |
| 258 attrs.update(kw) |
| 259 |
| 260 basedir = ensure_directory(os.getcwd(), '.recipe_runtime') |
| 261 try: |
| 262 tdir = tempfile.mkdtemp(dir=basedir) |
| 263 LOGGER.debug('Using temporary directory [%s].', tdir) |
| 264 |
| 265 attrs['workdir'] = tdir |
| 266 yield cls(**attrs) |
| 267 finally: |
| 268 if basedir and os.path.isdir(basedir): |
| 269 if not leak: |
| 270 LOGGER.debug('Cleaning up temporary directory [%s].', basedir) |
| 271 try: |
| 272 # TODO(pgervais): use infra_libs.rmtree instead. |
| 273 shutil.rmtree(basedir) |
| 274 except Exception: |
| 275 LOGGER.exception('Failed to clean up temporary directory [%s].', |
| 276 basedir) |
| 277 else: |
| 278 LOGGER.warning('(--leak) Leaking temporary directory [%s].', basedir) |
| 279 |
| 280 def __getattr__(self, key): |
| 281 # Class methods/variables. |
| 282 value = getattr(super(RecipeRuntime, self), key, self._SENTINEL) |
| 283 if value is not self._SENTINEL: |
| 284 return value |
| 285 |
| 286 value = getattr(self, 'get')(key, self._SENTINEL) |
| 287 if value is not self._SENTINEL: |
| 288 return value |
| 289 raise KeyError(key) |
| 290 |
| 291 def get(self, key, default=None): |
| 292 value = self._attrs.get(key, self._SENTINEL) |
| 293 if value is not self._SENTINEL: |
| 294 return value |
| 295 return default |
| 296 |
| 297 def __str__(self): |
| 298 return str(self._attrs) |
| 299 |
| 300 |
| 301 def _get_service_account_json(opts, credential_paths): |
| 302 """Returns (str/None): If specified, the path to the service account JSON. |
| 303 |
| 304 This method probes the local environemnt and returns a (possibly empty) list |
| 305 of arguments to add to the Butler command line for authentication. |
| 306 |
| 307 If we're running on a GCE instance, no arguments will be returned, as GCE |
| 308 service account is implicitly authenticated. If we're running on Baremetal, |
| 309 a path to those credentials will be returned. |
| 310 |
| 311 Args: |
| 312 rt (RecipeRuntime): The runtime environment. |
| 313 Raises: |
| 314 |LogDogBootstrapError| if no credentials could be found. |
| 315 """ |
| 316 path = opts.get('service_account_json') |
| 317 if path: |
| 318 return path |
| 319 |
| 320 if gce.Authenticator.is_gce(): |
| 321 LOGGER.info('Running on GCE. No credentials necessary.') |
| 322 return None |
| 323 |
| 324 for credential_path in credential_paths: |
| 325 candidate = os.path.join(credential_path, 'logdog_service_account.json') |
| 326 if os.path.isfile(candidate): |
| 327 return candidate |
| 328 |
| 329 raise LogDogBootstrapError('Could not find service account credentials. ' |
| 330 'Tried: %s' % (credential_paths,)) |
| 331 |
| 332 |
| 333 def _logdog_bootstrap(tempdir, config, opts, cmd): |
| 334 """Executes the recipe engine, bootstrapping it through LogDog/Annotee. |
| 335 |
| 336 This method executes the recipe engine, bootstrapping it through |
| 337 LogDog/Annotee so its output and annotations are streamed to LogDog. The |
| 338 bootstrap is configured to tee the annotations through STDOUT/STDERR so they |
| 339 will still be sent to BuildBot. |
| 340 |
| 341 The overall setup here is: |
| 342 [annotated_run.py] => [logdog_butler] => [logdog_annotee] => [recipes.py] |
| 343 |
| 344 Args: |
| 345 config (Config): Recipe runtime configuration. |
| 346 opts (argparse.Namespace): Command-line options. |
| 347 cmd (list): The recipe runner command list to bootstrap. |
| 348 |
| 349 Returns (int): The return code of the recipe runner process. |
| 350 |
| 351 Raises: |
| 352 LogDogNotBootstrapped: if the recipe engine was not executed because the |
| 353 LogDog bootstrap requirements are not available. |
| 354 LogDogBootstrapError: if there was an error bootstrapping the recipe runner |
| 355 through LogDog. |
| 356 """ |
| 357 bootstrap_dir = ensure_directory(tempdir, 'logdog_bootstrap') |
| 358 butler, annotee = opts.logdog_butler_path, opts.logdog_annotee_path |
| 359 |
| 360 if not is_executable(annotee): |
| 361 raise LogDogNotBootstrapped('Annotee is not executable: %s' % (annotee,)) |
| 362 if not is_executable(butler): |
| 363 raise LogDogNotBootstrapped('Butler is not executable: %s' % (butler,)) |
| 364 |
| 365 # Determine LogDog verbosity. |
| 366 logdog_verbose = [] |
| 367 if opts.logdog_verbose == 0: |
| 368 pass |
| 369 elif opts.logdog_verbose == 1: |
| 370 logdog_verbose.extend('-log_level=info') |
| 371 else: |
| 372 logdog_verbose.extend('-log_level=debug') |
| 373 |
| 374 service_account_args = [] |
| 375 service_account_json = _get_service_account_json( |
| 376 opts, config.credential_paths) |
| 377 if service_account_json: |
| 378 service_account_args += ['-service-account-json', service_account_json] |
| 379 |
| 380 streamserver_uri_gen = config.logdog_butler_streamserver_gen |
| 381 if not streamserver_uri_gen: |
| 382 raise LogDogBootstrapError('No streamserver URI generator.') |
| 383 streamserver_uri = streamserver_uri_gen(tempdir) |
| 384 |
| 385 # Dump Annotee command to JSON. |
| 386 cmd_json = os.path.join(bootstrap_dir, 'annotee_cmd.json') |
| 387 with open(cmd_json, 'w') as fd: |
| 388 json.dump(cmd, fd) |
| 389 |
| 390 cmd = [ |
| 391 # Butler Command. |
| 392 butler, |
| 393 ] + logdog_verbose + service_account_args + [ |
| 394 '-output', 'gcps,project="%s",topic="%s"' % (config.logdog_pubsub.project, |
| 395 config.logdog_pubsub.topic), |
| 396 'run', |
| 397 '-streamserver-uri', streamserver_uri, |
| 398 '--', |
| 399 |
| 400 # Annotee Command. |
| 401 annotee, |
| 402 ] + logdog_verbose + [ |
| 403 '-json-args-path', cmd_json, |
| 404 ] |
| 405 rv, _ = _run_command(cmd, dry_run=opts.dry_run) |
| 406 if rv in LOGDOG_ERROR_RETURNCODES: |
| 407 raise LogDogBootstrapError('LogDog Error (%d)' % (rv,)) |
| 408 return rv |
| 409 |
| 410 |
| 411 def _assert_logdog_whitelisted(mastername, buildername): |
| 412 """Asserts that the runtime environment is whitelisted for LogDog bootstrap. |
| 413 |
| 414 Args: |
| 415 mastername (str): The master name string. |
| 416 buildername (str): The builder name. |
| 417 Raises: |
| 418 LogDogNotBootstrapped: if the runtime is not whitelisted. |
| 419 """ |
| 420 if not all((mastername, buildername)): |
| 421 raise LogDogNotBootstrapped('Required mastername/buildername is not set.') |
| 422 |
| 423 # Key on mastername. |
| 424 bdict = LOGDOG_WHITELIST_MASTER_BUILDERS.get(mastername) |
| 425 if bdict is not None: |
| 426 # Key on buildername. |
| 427 if WHITELIST_ALL in bdict or buildername in bdict: |
| 428 LOGGER.info('Whitelisted master %s, builder %s.', |
| 429 mastername, buildername) |
| 430 return |
| 431 raise LogDogNotBootstrapped('Master %s, builder %s is not whitelisted.' % ( |
| 432 mastername, buildername)) |
| 433 |
| 434 |
147 def get_recipe_properties(workdir, build_properties, | 435 def get_recipe_properties(workdir, build_properties, |
148 use_factory_properties_from_disk): | 436 use_factory_properties_from_disk): |
149 """Constructs the recipe's properties from buildbot's properties. | 437 """Constructs the recipe's properties from buildbot's properties. |
150 | 438 |
151 This retrieves the current factory properties from the master_config | 439 This retrieves the current factory properties from the master_config |
152 in the slave's checkout (no factory properties are handed to us from the | 440 in the slave's checkout (no factory properties are handed to us from the |
153 master), and merges in the build properties. | 441 master), and merges in the build properties. |
154 | 442 |
155 Using the values from the checkout allows us to do things like change | 443 Using the values from the checkout allows us to do things like change |
156 the recipe and other factory properties for a builder without needing | 444 the recipe and other factory properties for a builder without needing |
(...skipping 127 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
284 help='factory properties in b64 gz JSON format') | 572 help='factory properties in b64 gz JSON format') |
285 parser.add_argument('--keep-stdin', action='store_true', default=False, | 573 parser.add_argument('--keep-stdin', action='store_true', default=False, |
286 help='don\'t close stdin when running recipe steps') | 574 help='don\'t close stdin when running recipe steps') |
287 parser.add_argument('--master-overrides-slave', action='store_true', | 575 parser.add_argument('--master-overrides-slave', action='store_true', |
288 help='use the property values given on the command line from the master, ' | 576 help='use the property values given on the command line from the master, ' |
289 'not the ones looked up on the slave') | 577 'not the ones looked up on the slave') |
290 parser.add_argument('--use-factory-properties-from-disk', | 578 parser.add_argument('--use-factory-properties-from-disk', |
291 action='store_true', default=False, | 579 action='store_true', default=False, |
292 help='use factory properties loaded from disk on the slave') | 580 help='use factory properties loaded from disk on the slave') |
293 | 581 |
| 582 group = parser.add_argument_group('LogDog Bootstrap') |
| 583 group.add_argument('-V', '--logdog-verbose', |
| 584 action='count', default=0, |
| 585 help='Increase LogDog verbosity. This can be specified multiple times.') |
| 586 group.add_argument('-f', '--logdog-force', action='store_true', |
| 587 help='Force LogDog bootstrapping, even if the system is not configured.') |
| 588 group.add_argument('--logdog-butler-path', |
| 589 help='Path to the LogDog Butler. If empty, one will be probed/downloaded ' |
| 590 'from CIPD.') |
| 591 group.add_argument('--logdog-annotee-path', |
| 592 help='Path to the LogDog Annotee. If empty, one will be ' |
| 593 'probed/downloaded from CIPD.') |
| 594 group.add_argument('--logdog-service-account-json', |
| 595 help='Path to the service account JSON. If one is not provided, the ' |
| 596 'local system credentials will be used.') |
| 597 |
294 return parser.parse_args(argv) | 598 return parser.parse_args(argv) |
295 | 599 |
296 | 600 |
297 def update_scripts(): | 601 def update_scripts(): |
298 if os.environ.get('RUN_SLAVE_UPDATED_SCRIPTS'): | 602 if os.environ.get('RUN_SLAVE_UPDATED_SCRIPTS'): |
299 os.environ.pop('RUN_SLAVE_UPDATED_SCRIPTS') | 603 os.environ.pop('RUN_SLAVE_UPDATED_SCRIPTS') |
300 return False | 604 return False |
301 | 605 |
302 stream = annotator.StructuredAnnotationStream() | 606 stream = annotator.StructuredAnnotationStream() |
303 | 607 |
(...skipping 166 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
470 with open(props_file, 'w') as fh: | 774 with open(props_file, 'w') as fh: |
471 json.dump(properties, fh) | 775 json.dump(properties, fh) |
472 cmd = [ | 776 cmd = [ |
473 sys.executable, '-u', recipe_runner, | 777 sys.executable, '-u', recipe_runner, |
474 'run', | 778 'run', |
475 '--workdir=%s' % os.getcwd(), | 779 '--workdir=%s' % os.getcwd(), |
476 '--properties-file=%s' % props_file, | 780 '--properties-file=%s' % props_file, |
477 properties['recipe'], | 781 properties['recipe'], |
478 ] | 782 ] |
479 | 783 |
480 status, _ = _run_command(cmd, dry_run=opts.dry_run) | 784 status = None |
| 785 try: |
| 786 if not opts.logdog_force: |
| 787 _assert_logdog_whitelisted(config.mastername, config.buildername) |
| 788 status = _logdog_bootstrap(tdir, config, opts, cmd) |
| 789 except LogDogNotBootstrapped as e: |
| 790 LOGGER.info('Not bootstrapped: %s', e.message) |
| 791 except LogDogBootstrapError as e: |
| 792 LOGGER.warning('Could not bootstrap LogDog: %s', e.message) |
| 793 except Exception: |
| 794 LOGGER.exception('Exception while bootstrapping LogDog.') |
| 795 finally: |
| 796 if status is None: |
| 797 LOGGER.info('Not using LogDog. Invoking `annotated_run.py` directly.') |
| 798 status, _ = _run_command(cmd, dry_run=opts.dry_run) |
481 | 799 |
482 return status | 800 return status |
483 | 801 |
484 | 802 |
485 def shell_main(argv): | 803 def shell_main(argv): |
486 if update_scripts(): | 804 if update_scripts(): |
487 # Re-execute with the updated annotated_run.py. | 805 # Re-execute with the updated annotated_run.py. |
488 rv, _ = _run_command([sys.executable] + argv) | 806 rv, _ = _run_command([sys.executable] + argv) |
489 return rv | 807 return rv |
490 else: | 808 else: |
491 return main(argv[1:]) | 809 return main(argv[1:]) |
492 | 810 |
493 | 811 |
494 if __name__ == '__main__': | 812 if __name__ == '__main__': |
495 logging.basicConfig(level=logging.INFO) | 813 logging.basicConfig(level=logging.INFO) |
496 sys.exit(shell_main(sys.argv)) | 814 sys.exit(shell_main(sys.argv)) |
OLD | NEW |