1#!/usr/bin/env python 2 3""" 4CI build script 5(C) 2017,2020 Jack Lloyd 6 7Botan is released under the Simplified BSD License (see license.txt) 8""" 9 10import os 11import platform 12import subprocess 13import sys 14import time 15import tempfile 16import optparse # pylint: disable=deprecated-module 17 18def get_concurrency(): 19 def_concurrency = 2 20 21 try: 22 import multiprocessing 23 return multiprocessing.cpu_count() 24 except ImportError: 25 return def_concurrency 26 27def build_targets(target, target_os): 28 if target in ['shared', 'minimized', 'bsi', 'nist']: 29 yield 'shared' 30 elif target in ['static', 'fuzzers', 'baremetal']: 31 yield 'static' 32 elif target_os in ['windows']: 33 yield 'shared' 34 elif target_os in ['ios', 'mingw']: 35 yield 'static' 36 else: 37 yield 'shared' 38 yield 'static' 39 40 yield 'cli' 41 yield 'tests' 42 43 if target in ['coverage']: 44 yield 'bogo_shim' 45 46def determine_flags(target, target_os, target_cpu, target_cc, cc_bin, 47 ccache, root_dir, pkcs11_lib, use_gdb, disable_werror, extra_cxxflags, 48 disabled_tests): 49 # pylint: disable=too-many-branches,too-many-statements,too-many-arguments,too-many-locals 50 51 """ 52 Return the configure.py flags as well as make/test running prefixes 53 """ 54 is_cross_target = target.startswith('cross-') 55 56 if target_os not in ['linux', 'osx', 'windows', 'freebsd']: 57 print('Error unknown OS %s' % (target_os)) 58 return (None, None, None) 59 60 if is_cross_target: 61 if target_os == 'osx': 62 target_os = 'ios' 63 elif target == 'cross-win64': 64 target_os = 'mingw' 65 elif target in ['cross-android-arm32', 'cross-android-arm64']: 66 target_os = 'android' 67 68 if target_os == 'windows' and target_cc == 'gcc': 69 target_os = 'mingw' 70 71 if target == 'baremetal': 72 target_os = 'none' 73 74 make_prefix = [] 75 test_prefix = [] 76 test_cmd = [os.path.join(root_dir, 'botan-test')] 77 78 install_prefix = tempfile.mkdtemp(prefix='botan-install-') 79 80 flags = ['--prefix=%s' % (install_prefix), 81 '--cc=%s' % (target_cc), 82 '--os=%s' % (target_os), 83 '--build-targets=%s' % ','.join(build_targets(target, target_os))] 84 85 if ccache is not None: 86 flags += ['--no-store-vc-rev', '--compiler-cache=%s' % (ccache)] 87 88 if target_os != 'osx' and not disable_werror: 89 flags += ['--werror-mode'] 90 91 if target_cpu is not None: 92 flags += ['--cpu=%s' % (target_cpu)] 93 94 for flag in extra_cxxflags: 95 flags += ['--extra-cxxflags=%s' % (flag)] 96 97 if target in ['minimized']: 98 flags += ['--minimized-build', '--enable-modules=system_rng,sha2_32,sha2_64,aes'] 99 100 if target in ['bsi', 'nist']: 101 # tls is optional for bsi/nist but add it so verify tests work with these minimized configs 102 flags += ['--module-policy=%s' % (target), '--enable-modules=tls'] 103 104 if target == 'docs': 105 flags += ['--with-doxygen', '--with-sphinx', '--with-rst2man'] 106 test_cmd = None 107 108 if target == 'cross-win64': 109 # this test compiles under MinGW but fails when run under Wine 110 disabled_tests.append('certstor_system') 111 112 if target == 'coverage': 113 flags += ['--with-coverage-info', '--with-debug-info', '--test-mode'] 114 115 if target == 'valgrind': 116 flags += ['--with-valgrind'] 117 test_prefix = ['valgrind', '--error-exitcode=9', '-v', '--leak-check=full', '--show-reachable=yes'] 118 # valgrind is single threaded anyway 119 test_cmd += ['--test-threads=1'] 120 # valgrind is slow 121 slow_tests = [ 122 'cryptobox', 'dh_invalid', 'dh_kat', 'dh_keygen', 123 'dl_group_gen', 'dlies', 'dsa_param', 'ecc_basemul', 124 'ecdsa_verify_wycheproof', 'mce_keygen', 'passhash9', 125 'rsa_encrypt', 'rsa_pss', 'rsa_pss_raw', 'scrypt', 126 'srp6_kat', 'x509_path_bsi', 'xmss_keygen', 'xmss_sign', 127 'pbkdf', 'argon2', 'bcrypt', 'bcrypt_pbkdf', 'compression', 128 'ed25519_sign', 'elgamal_keygen', 'x509_path_rsa_pss'] 129 130 disabled_tests += slow_tests 131 132 if target == 'fuzzers': 133 flags += ['--unsafe-fuzzer-mode'] 134 135 if target in ['fuzzers', 'coverage']: 136 flags += ['--build-fuzzers=test'] 137 138 if target in ['fuzzers', 'sanitizer']: 139 flags += ['--with-debug-asserts'] 140 141 if target_cc in ['clang', 'gcc']: 142 flags += ['--enable-sanitizers=address,undefined'] 143 else: 144 flags += ['--with-sanitizers'] 145 146 if target in ['valgrind', 'sanitizer', 'fuzzers']: 147 flags += ['--disable-modules=locking_allocator'] 148 149 if target == 'baremetal': 150 cc_bin = 'arm-none-eabi-c++' 151 flags += ['--cpu=arm32', '--disable-neon', '--without-stack-protector', '--ldflags=-specs=nosys.specs'] 152 test_cmd = None 153 154 if is_cross_target: 155 if target_os == 'ios': 156 make_prefix = ['xcrun', '--sdk', 'iphoneos'] 157 test_cmd = None 158 if target == 'cross-ios-arm64': 159 flags += ['--cpu=arm64', '--cc-abi-flags=-arch arm64 -stdlib=libc++'] 160 else: 161 raise Exception("Unknown cross target '%s' for iOS" % (target)) 162 elif target_os == 'android': 163 164 ndk = os.getenv('ANDROID_NDK') 165 if ndk is None: 166 raise Exception('Android CI build requires ANDROID_NDK env variable be set') 167 168 api_lvl = int(os.getenv('ANDROID_API_LEVEL', '0')) 169 if api_lvl == 0: 170 # If not set arbitrarily choose API 16 (Android 4.1) for ARMv7 and 28 (Android 9) for AArch64 171 api_lvl = 16 if target == 'cross-android-arm32' else 28 172 173 toolchain_dir = os.path.join(ndk, 'toolchains/llvm/prebuilt/linux-x86_64/bin') 174 test_cmd = None 175 176 if target == 'cross-android-arm32': 177 cc_bin = os.path.join(toolchain_dir, 'armv7a-linux-androideabi%d-clang++' % (api_lvl)) 178 flags += ['--cpu=armv7', 179 '--ar-command=%s' % (os.path.join(toolchain_dir, 'arm-linux-androideabi-ar'))] 180 elif target == 'cross-android-arm64': 181 cc_bin = os.path.join(toolchain_dir, 'aarch64-linux-android%d-clang++' % (api_lvl)) 182 flags += ['--cpu=arm64', 183 '--ar-command=%s' % (os.path.join(toolchain_dir, 'aarch64-linux-android-ar'))] 184 185 if api_lvl < 18: 186 flags += ['--without-os-features=getauxval'] 187 if api_lvl >= 28: 188 flags += ['--with-os-features=getentropy'] 189 190 elif target == 'cross-i386': 191 flags += ['--cpu=x86_32'] 192 193 elif target == 'cross-win64': 194 # MinGW in 16.04 is lacking std::mutex for unknown reason 195 cc_bin = 'x86_64-w64-mingw32-g++' 196 flags += ['--cpu=x86_64', '--cc-abi-flags=-static', 197 '--ar-command=x86_64-w64-mingw32-ar', '--without-os-feature=threads'] 198 test_cmd = [os.path.join(root_dir, 'botan-test.exe')] + test_cmd[1:] 199 test_prefix = ['wine'] 200 else: 201 if target == 'cross-arm32': 202 flags += ['--cpu=armv7'] 203 cc_bin = 'arm-linux-gnueabihf-g++' 204 # Currently arm32 CI only runs on native AArch64 205 #test_prefix = ['qemu-arm', '-L', '/usr/arm-linux-gnueabihf/'] 206 elif target == 'cross-arm64': 207 flags += ['--cpu=aarch64'] 208 cc_bin = 'aarch64-linux-gnu-g++' 209 test_prefix = ['qemu-aarch64', '-L', '/usr/aarch64-linux-gnu/'] 210 elif target == 'cross-ppc32': 211 flags += ['--cpu=ppc32'] 212 cc_bin = 'powerpc-linux-gnu-g++' 213 test_prefix = ['qemu-ppc', '-L', '/usr/powerpc-linux-gnu/'] 214 elif target == 'cross-ppc64': 215 flags += ['--cpu=ppc64', '--with-endian=little'] 216 cc_bin = 'powerpc64le-linux-gnu-g++' 217 test_prefix = ['qemu-ppc64le', '-cpu', 'POWER8', '-L', '/usr/powerpc64le-linux-gnu/'] 218 elif target == 'cross-mips64': 219 flags += ['--cpu=mips64', '--with-endian=big'] 220 cc_bin = 'mips64-linux-gnuabi64-g++' 221 test_prefix = ['qemu-mips64', '-L', '/usr/mips64-linux-gnuabi64/'] 222 test_cmd.remove('simd_32') # no SIMD on MIPS 223 else: 224 raise Exception("Unknown cross target '%s' for Linux" % (target)) 225 else: 226 # Flags specific to native targets 227 228 if target_os in ['osx', 'linux']: 229 flags += ['--with-bzip2', '--with-sqlite', '--with-zlib'] 230 231 if target_os in ['osx', 'ios']: 232 flags += ['--with-commoncrypto'] 233 234 if target == 'coverage': 235 flags += ['--with-boost'] 236 237 if target_os == 'windows' and target in ['shared', 'static']: 238 # ./configure.py needs extra hand-holding for boost on windows 239 boost_root = os.environ.get('BOOST_ROOT') 240 boost_libs = os.environ.get('BOOST_LIBRARYDIR') 241 boost_system = os.environ.get('BOOST_SYSTEM_LIBRARY') 242 243 if boost_root and boost_libs and boost_system: 244 flags += ['--with-boost', 245 '--with-external-includedir', boost_root, 246 '--with-external-libdir', boost_libs, 247 '--boost-library-name', boost_system] 248 249 if target_os == 'linux': 250 flags += ['--with-lzma'] 251 252 if target_os == 'linux': 253 if target not in ['sanitizer', 'valgrind', 'minimized']: 254 # Avoid OpenSSL when using dynamic checkers, or on OS X where it sporadically 255 # is not installed on the CI image 256 flags += ['--with-openssl'] 257 258 if target in ['coverage']: 259 flags += ['--with-tpm'] 260 test_cmd += ['--run-online-tests'] 261 if pkcs11_lib and os.access(pkcs11_lib, os.R_OK): 262 test_cmd += ['--pkcs11-lib=%s' % (pkcs11_lib)] 263 264 if target in ['coverage', 'sanitizer']: 265 test_cmd += ['--run-long-tests'] 266 267 flags += ['--cc-bin=%s' % (cc_bin)] 268 269 if test_cmd is None: 270 run_test_command = None 271 else: 272 if use_gdb: 273 disabled_tests.append("os_utils") 274 275 # render 'disabled_tests' array into test_cmd 276 if disabled_tests: 277 test_cmd += ['--skip-tests=%s' % (','.join(disabled_tests))] 278 279 if use_gdb: 280 (cmd, args) = test_cmd[0], test_cmd[1:] 281 run_test_command = test_prefix + ['gdb', cmd, 282 '-ex', 'run %s' % (' '.join(args)), 283 '-ex', 'bt', 284 '-ex', 'quit'] 285 else: 286 run_test_command = test_prefix + test_cmd 287 288 return flags, run_test_command, make_prefix 289 290def run_cmd(cmd, root_dir): 291 """ 292 Execute a command, die if it failed 293 """ 294 print("Running '%s' ..." % (' '.join(cmd))) 295 sys.stdout.flush() 296 297 start = time.time() 298 299 cmd = [os.path.expandvars(elem) for elem in cmd] 300 sub_env = os.environ.copy() 301 sub_env['LD_LIBRARY_PATH'] = os.path.abspath(root_dir) 302 sub_env['DYLD_LIBRARY_PATH'] = os.path.abspath(root_dir) 303 sub_env['PYTHONPATH'] = os.path.abspath(os.path.join(root_dir, 'src/python')) 304 cwd = None 305 306 redirect_stdout = None 307 if len(cmd) >= 3 and cmd[-2] == '>': 308 redirect_stdout = open(cmd[-1], 'w') 309 cmd = cmd[:-2] 310 if len(cmd) > 1 and cmd[0].startswith('indir:'): 311 cwd = cmd[0][6:] 312 cmd = cmd[1:] 313 while len(cmd) > 1 and cmd[0].startswith('env:') and cmd[0].find('=') > 0: 314 env_key, env_val = cmd[0][4:].split('=') 315 sub_env[env_key] = env_val 316 cmd = cmd[1:] 317 318 proc = subprocess.Popen(cmd, cwd=cwd, close_fds=True, env=sub_env, stdout=redirect_stdout) 319 proc.communicate() 320 321 time_taken = int(time.time() - start) 322 323 if time_taken > 10: 324 print("Ran for %d seconds" % (time_taken)) 325 326 if proc.returncode != 0: 327 print("Command '%s' failed with error code %d" % (' '.join(cmd), proc.returncode)) 328 329 if cmd[0] not in ['lcov']: 330 sys.exit(proc.returncode) 331 332def default_os(): 333 platform_os = platform.system().lower() 334 if platform_os == 'darwin': 335 return 'osx' 336 return platform_os 337 338def parse_args(args): 339 """ 340 Parse arguments 341 """ 342 parser = optparse.OptionParser() 343 344 parser.add_option('--os', default=default_os(), 345 help='Set the target os (default %default)') 346 parser.add_option('--cc', default='gcc', 347 help='Set the target compiler type (default %default)') 348 parser.add_option('--cc-bin', default=None, 349 help='Set path to compiler') 350 parser.add_option('--root-dir', metavar='D', default='.', 351 help='Set directory to execute from (default %default)') 352 353 parser.add_option('--make-tool', metavar='TOOL', default='make', 354 help='Specify tool to run to build source (default %default)') 355 356 parser.add_option('--extra-cxxflags', metavar='FLAGS', default=[], action='append', 357 help='Specify extra build flags') 358 359 parser.add_option('--cpu', default=None, 360 help='Specify a target CPU platform') 361 362 parser.add_option('--with-debug', action='store_true', default=False, 363 help='Include debug information') 364 parser.add_option('--amalgamation', action='store_true', default=False, 365 help='Build via amalgamation') 366 parser.add_option('--disable-shared', action='store_true', default=False, 367 help='Disable building shared libraries') 368 parser.add_option('--disabled-tests', metavar='DISABLED_TESTS', default=[], action='append', 369 help='Comma separated list of tests that should not be run') 370 371 parser.add_option('--branch', metavar='B', default=None, 372 help='Specify branch being built') 373 374 parser.add_option('--dry-run', action='store_true', default=False, 375 help='Just show commands to be executed') 376 parser.add_option('--build-jobs', metavar='J', default=get_concurrency(), 377 help='Set number of jobs to run in parallel (default %default)') 378 379 parser.add_option('--compiler-cache', default=None, metavar='CC', 380 help='Set a compiler cache to use (ccache, sccache)') 381 382 parser.add_option('--pkcs11-lib', default=os.getenv('PKCS11_LIB'), metavar='LIB', 383 help='Set PKCS11 lib to use for testing') 384 385 parser.add_option('--with-python3', dest='use_python3', action='store_true', default=None, 386 help='Enable using python3') 387 parser.add_option('--without-python3', dest='use_python3', action='store_false', 388 help='Disable using python3') 389 390 parser.add_option('--with-pylint3', dest='use_pylint3', action='store_true', default=True, 391 help='Enable using python3 pylint') 392 parser.add_option('--without-pylint3', dest='use_pylint3', action='store_false', 393 help='Disable using python3 pylint') 394 395 parser.add_option('--disable-werror', action='store_true', default=False, 396 help='Allow warnings to compile') 397 398 parser.add_option('--run-under-gdb', dest='use_gdb', action='store_true', default=False, 399 help='Run test suite under gdb and capture backtrace') 400 401 return parser.parse_args(args) 402 403def have_prog(prog): 404 """ 405 Check if some named program exists in the path 406 """ 407 for path in os.environ['PATH'].split(os.pathsep): 408 exe_file = os.path.join(path, prog) 409 if os.path.exists(exe_file) and os.access(exe_file, os.X_OK): 410 return True 411 return False 412 413def main(args=None): 414 # pylint: disable=too-many-branches,too-many-statements,too-many-locals,too-many-return-statements,too-many-locals 415 """ 416 Parse options, do the things 417 """ 418 419 if os.getenv('COVERITY_SCAN_BRANCH') == '1': 420 print('Skipping build COVERITY_SCAN_BRANCH set in environment') 421 return 0 422 423 if args is None: 424 args = sys.argv 425 print("Invoked as '%s'" % (' '.join(args))) 426 (options, args) = parse_args(args) 427 428 if len(args) != 2: 429 print('Usage: %s [options] target' % (args[0])) 430 return 1 431 432 target = args[1] 433 434 if options.use_python3 is None: 435 use_python3 = have_prog('python3') 436 else: 437 use_python3 = options.use_python3 438 439 py_interp = 'python' 440 if use_python3: 441 py_interp = 'python3' 442 443 if options.cc_bin is None: 444 if options.cc == 'gcc': 445 options.cc_bin = 'g++' 446 elif options.cc == 'clang': 447 options.cc_bin = 'clang++' 448 elif options.cc == 'msvc': 449 options.cc_bin = 'cl' 450 else: 451 print('Error unknown compiler %s' % (options.cc)) 452 return 1 453 454 if options.compiler_cache is None and options.cc != 'msvc': 455 # Autodetect ccache 456 if have_prog('ccache'): 457 options.compiler_cache = 'ccache' 458 459 if options.compiler_cache not in [None, 'ccache', 'sccache']: 460 raise Exception("Don't know about %s as a compiler cache" % (options.compiler_cache)) 461 462 root_dir = options.root_dir 463 464 if not os.access(root_dir, os.R_OK): 465 raise Exception('Bad root dir setting, dir %s not readable' % (root_dir)) 466 467 cmds = [] 468 469 if target == 'lint': 470 471 pylint_rc = '--rcfile=%s' % (os.path.join(root_dir, 'src/configs/pylint.rc')) 472 pylint_flags = [pylint_rc, '--reports=no'] 473 474 # Some disabled rules specific to Python3 475 # useless-object-inheritance: complains about code still useful in Python2 476 py3_flags = '--disable=useless-object-inheritance' 477 478 py_scripts = [ 479 'configure.py', 480 'src/python/botan2.py', 481 'src/scripts/ci_build.py', 482 'src/scripts/install.py', 483 'src/scripts/ci_check_install.py', 484 'src/scripts/dist.py', 485 'src/scripts/cleanup.py', 486 'src/scripts/check.py', 487 'src/scripts/build_docs.py', 488 'src/scripts/website.py', 489 'src/scripts/bench.py', 490 'src/scripts/test_python.py', 491 'src/scripts/test_fuzzers.py', 492 'src/scripts/test_cli.py', 493 'src/scripts/python_unittests.py', 494 'src/scripts/python_unittests_unix.py'] 495 496 full_paths = [os.path.join(root_dir, s) for s in py_scripts] 497 498 if use_python3 and options.use_pylint3: 499 cmds.append(['python3', '-m', 'pylint'] + pylint_flags + [py3_flags] + full_paths) 500 501 else: 502 config_flags, run_test_command, make_prefix = determine_flags( 503 target, options.os, options.cpu, options.cc, 504 options.cc_bin, options.compiler_cache, root_dir, 505 options.pkcs11_lib, options.use_gdb, options.disable_werror, 506 options.extra_cxxflags, options.disabled_tests) 507 508 cmds.append([py_interp, os.path.join(root_dir, 'configure.py')] + config_flags) 509 510 make_cmd = [options.make_tool] 511 if root_dir != '.': 512 make_cmd += ['-C', root_dir] 513 if options.build_jobs > 1 and options.make_tool != 'nmake': 514 make_cmd += ['-j%d' % (options.build_jobs)] 515 make_cmd += ['-k'] 516 517 if target == 'docs': 518 cmds.append(make_cmd + ['docs']) 519 else: 520 if options.compiler_cache is not None: 521 cmds.append([options.compiler_cache, '--show-stats']) 522 523 make_targets = ['libs', 'tests', 'cli'] 524 525 if target in ['coverage', 'fuzzers']: 526 make_targets += ['fuzzer_corpus_zip', 'fuzzers'] 527 528 if target in ['coverage']: 529 make_targets += ['bogo_shim'] 530 531 cmds.append(make_prefix + make_cmd + make_targets) 532 533 if options.compiler_cache is not None: 534 cmds.append([options.compiler_cache, '--show-stats']) 535 536 if run_test_command is not None: 537 cmds.append(run_test_command) 538 539 if target == 'coverage': 540 runner_dir = os.path.abspath(os.path.join(root_dir, 'boringssl', 'ssl', 'test', 'runner')) 541 542 cmds.append(['indir:%s' % (runner_dir), 543 'go', 'test', '-pipe', 544 '-num-workers', str(4*get_concurrency()), 545 '-shim-path', os.path.abspath(os.path.join(root_dir, 'botan_bogo_shim')), 546 '-shim-config', os.path.abspath(os.path.join(root_dir, 'src', 'bogo_shim', 'config.json'))]) 547 548 if target in ['coverage', 'fuzzers']: 549 cmds.append([py_interp, os.path.join(root_dir, 'src/scripts/test_fuzzers.py'), 550 os.path.join(root_dir, 'fuzzer_corpus'), 551 os.path.join(root_dir, 'build/fuzzer')]) 552 553 if target in ['shared', 'coverage'] and options.os != 'windows': 554 botan_exe = os.path.join(root_dir, 'botan-cli.exe' if options.os == 'windows' else 'botan') 555 556 args = ['--threads=%d' % (options.build_jobs)] 557 test_scripts = ['test_cli.py', 'test_cli_crypt.py'] 558 for script in test_scripts: 559 cmds.append([py_interp, os.path.join(root_dir, 'src/scripts', script)] + 560 args + [botan_exe]) 561 562 python_tests = os.path.join(root_dir, 'src/scripts/test_python.py') 563 564 if target in ['shared', 'coverage']: 565 566 if options.os == 'windows': 567 if options.cpu == 'x86': 568 # Python on AppVeyor is a 32-bit binary so only test for 32-bit 569 cmds.append([py_interp, '-b', python_tests]) 570 else: 571 if use_python3: 572 cmds.append(['python3', '-b', python_tests]) 573 574 if target in ['shared', 'static', 'bsi', 'nist']: 575 cmds.append(make_cmd + ['install']) 576 build_config = os.path.join(root_dir, 'build', 'build_config.json') 577 cmds.append([py_interp, os.path.join(root_dir, 'src/scripts/ci_check_install.py'), build_config]) 578 579 if target in ['coverage']: 580 if not have_prog('lcov'): 581 print('Error: lcov not found in PATH (%s)' % (os.getenv('PATH'))) 582 return 1 583 584 if not have_prog('gcov'): 585 print('Error: gcov not found in PATH (%s)' % (os.getenv('PATH'))) 586 return 1 587 588 cov_file = 'coverage.info' 589 raw_cov_file = 'coverage.info.raw' 590 591 cmds.append(['lcov', '--capture', '--directory', options.root_dir, 592 '--output-file', raw_cov_file]) 593 cmds.append(['lcov', '--remove', raw_cov_file, '/usr/*', '--output-file', cov_file]) 594 cmds.append(['lcov', '--list', cov_file]) 595 596 if have_prog('coverage'): 597 cmds.append(['coverage', 'run', '--branch', 598 '--rcfile', os.path.join(root_dir, 'src/configs/coverage.rc'), 599 python_tests]) 600 601 if have_prog('codecov'): 602 # If codecov exists assume we are in CI and report to codecov.io 603 cmds.append(['codecov', '>', 'codecov_stdout.log']) 604 else: 605 # Otherwise generate a local HTML report 606 cmds.append(['genhtml', cov_file, '--output-directory', 'lcov-out']) 607 608 cmds.append(make_cmd + ['clean']) 609 cmds.append(make_cmd + ['distclean']) 610 611 for cmd in cmds: 612 if options.dry_run: 613 print('$ ' + ' '.join(cmd)) 614 else: 615 run_cmd(cmd, root_dir) 616 617 return 0 618 619if __name__ == '__main__': 620 sys.exit(main()) 621