diff options
| author | Alexander Taylor <alex@vector35.com> | 2026-06-06 13:24:49 -0400 |
|---|---|---|
| committer | Alexander Taylor <alex@vector35.com> | 2026-06-06 14:03:58 -0400 |
| commit | 61b068347672bf2dbea4ee31d7080d32a3b919fe (patch) | |
| tree | 047824bf34cfb9475565f2ada3cd2bbf8536b4bd /scripts/test_build_external.py | |
| parent | 6bc36d9f834900d8b9b15ecc6956aaa3d39bf153 (diff) | |
Split internal/external test build scripts.
Also make them a little more configurable.
Diffstat (limited to 'scripts/test_build_external.py')
| -rw-r--r-- | scripts/test_build_external.py | 215 |
1 files changed, 215 insertions, 0 deletions
diff --git a/scripts/test_build_external.py b/scripts/test_build_external.py new file mode 100644 index 00000000..e103c2b1 --- /dev/null +++ b/scripts/test_build_external.py @@ -0,0 +1,215 @@ +import argparse +import glob +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime + +parser = argparse.ArgumentParser( + description='Test building the API repo and plugins out-of-tree (for CI checking of end-user workflow)' +) +parser.add_argument('--headless', default=False, action='store_true', help='Only include headless plugins') +parser.add_argument('-j', '--parallel', default=4, help='Number of parallel jobs to tell cmake to run.') +parser.add_argument('--bn-install-dir', default=os.environ.get('BN_INSTALL_DIR'), + help='Path to a Binary Ninja installation. Defaults to BN_INSTALL_DIR.') +parser.add_argument('--qt-install-dir', default=os.environ.get('QT_INSTALL_DIR'), + help='Path to a Qt installation. Defaults to QT_INSTALL_DIR.') +parser.add_argument('--qmake', default=os.environ.get('QMAKE'), help='Path to qmake. Defaults to QMAKE or PATH lookup.') +parser.add_argument('--cmake', default=os.environ.get('CMAKE'), help='Path to cmake. Defaults to CMAKE or PATH lookup.') +parser.add_argument('--cc', default=os.environ.get('CC'), help='C compiler to use. Defaults to CC or CMake platform default.') +parser.add_argument('--cxx', default=os.environ.get('CXX'), help='C++ compiler to use. Defaults to CXX or CMake platform default.') +parser.add_argument('--extra-project', action='append', default=[], + help='Additional out-of-tree CMake project directory to build. May be specified multiple times.') +args = parser.parse_args() + +api_base = Path(__file__).parent.parent.absolute() + + +def fail(message): + print(f'ERROR: {message}') + sys.exit(1) + + +def prepend_path(env, path): + env['PATH'] = f'{path}{os.pathsep}{env.get("PATH", "")}' + + +def find_executable(name, explicit_path=None, env=None): + if explicit_path: + path = Path(explicit_path).expanduser() + if path.is_file(): + return str(path) + fail(f'{name} was specified as {path}, but that file does not exist.') + + return shutil.which(name, path=(env or os.environ).get('PATH')) + + +def run_tool(tool, args_for_tool, env=None): + try: + result = subprocess.run([tool] + args_for_tool, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + output = e.stdout.strip() if e.stdout else '' + fail(f'{tool} failed with exit code {e.returncode}.\n{output}') + + +def print_tool(tool_name, tool_path, version_args, env=None): + print(f'{tool_name}: {tool_path}') + version = run_tool(tool_path, version_args, env=env) + print(version) + + +def configure_qt_env(env): + qt_dir = Path(args.qt_install_dir).expanduser().absolute() if args.qt_install_dir else None + if qt_dir: + if not qt_dir.exists(): + fail(f'Qt directory was specified as {qt_dir}, but it does not exist.') + qt_bin_dir = qt_dir / 'bin' + if not qt_bin_dir.exists(): + fail(f'Qt directory {qt_dir} does not contain a bin directory.') + prepend_path(env, qt_bin_dir) + env.setdefault('CMAKE_PREFIX_PATH', str(qt_dir / 'lib' / 'cmake')) + print(f'Qt directory: {qt_dir}') + print(f'CMAKE_PREFIX_PATH: {env["CMAKE_PREFIX_PATH"]}') + + if args.headless: + return None + + qmake = find_executable('qmake', args.qmake, env=env) + if not qmake: + fail('qmake not found. Install Qt and add its bin directory to PATH, set QT_INSTALL_DIR, or pass --qt-install-dir/--qmake.') + + print_tool('qmake', qmake, ['--version'], env=env) + qt_lib_path = run_tool(qmake, ['-query', 'QT_INSTALL_LIBS'], env=env) + qt_cmake_path = str(Path(qt_lib_path) / 'cmake') + env.setdefault('CMAKE_PREFIX_PATH', qt_cmake_path) + print(f'Qt libraries: {qt_lib_path}') + print(f'CMAKE_PREFIX_PATH: {env["CMAKE_PREFIX_PATH"]}') + return qmake + + +def check_binary_ninja_install(env): + bn_install_dir = Path(args.bn_install_dir).expanduser().absolute() if args.bn_install_dir else None + if not bn_install_dir: + fail('Binary Ninja install directory was not specified. Set BN_INSTALL_DIR or pass --bn-install-dir.') + if not bn_install_dir.exists(): + fail(f'Binary Ninja install directory {bn_install_dir} does not exist.') + + env['BN_INSTALL_DIR'] = str(bn_install_dir) + if platform.system() == 'Darwin': + core_candidates = [bn_install_dir / 'Contents' / 'MacOS' / 'libbinaryninjacore.dylib'] + ui_candidates = [bn_install_dir / 'Contents' / 'MacOS' / 'libbinaryninjaui.dylib'] + elif platform.system() == 'Windows': + core_candidates = [bn_install_dir / 'binaryninjacore.dll'] + ui_candidates = [bn_install_dir / 'binaryninjaui.dll'] + else: + core_candidates = [bn_install_dir / 'libbinaryninjacore.so.1', bn_install_dir / 'libbinaryninjacore.so'] + ui_candidates = [bn_install_dir / 'libbinaryninjaui.so.1', bn_install_dir / 'libbinaryninjaui.so'] + + core = next((p for p in core_candidates if p.exists()), None) + if not core: + fail(f'Binary Ninja Core was not found in {bn_install_dir}. Checked: {", ".join(str(p) for p in core_candidates)}') + print(f'Binary Ninja install: {bn_install_dir}') + print(f'Binary Ninja Core: {core}') + + ui = next((p for p in ui_candidates if p.exists()), None) + if args.headless: + print('Binary Ninja UI: skipped because --headless was specified') + elif ui: + print(f'Binary Ninja UI: {ui}') + else: + fail(f'Binary Ninja UI was not found in {bn_install_dir}. Checked: {", ".join(str(p) for p in ui_candidates)}') + + +def contains_ui_dependency(cmake_lists): + with open(cmake_lists, 'r', encoding='utf-8') as cmake_file: + contents = cmake_file.read() + return 'binaryninjaui' in contents or 'Qt6::' in contents or 'find_package(Qt6' in contents + + +def external_project_cmake_files(): + cmake_files = [] + + # The examples are the supported customer-facing out-of-tree CMake projects + for f in glob.glob(str(api_base / 'examples' / '**' / 'CMakeLists.txt'), recursive=True): + cmake_path = Path(f) + if cmake_path.parent == api_base / 'examples': + continue + cmake_files.append(cmake_path) + + for project in args.extra_project: + project_path = Path(project).expanduser() + if not project_path.is_absolute(): + project_path = api_base / project_path + cmake_path = project_path / 'CMakeLists.txt' + if not cmake_path.exists(): + fail(f'Extra project {project_path} does not contain a CMakeLists.txt file.') + cmake_files.append(cmake_path) + + return sorted(cmake_files) + + +configure_env = os.environ.copy() +configure_qt_env(configure_env) +check_binary_ninja_install(configure_env) + +if args.cc: + configure_env['CC'] = args.cc +if args.cxx: + configure_env['CXX'] = args.cxx + +cmake = find_executable('cmake', args.cmake, env=configure_env) +if not cmake: + fail('cmake not found. Install CMake and add it to PATH, set CMAKE, or pass --cmake.') +print_tool('cmake', cmake, ['--version'], env=configure_env) + +print(f'C compiler: {configure_env.get("CC", "CMake platform default")}') +print(f'C++ compiler: {configure_env.get("CXX", "CMake platform default")}') + +configure_args = [] +build_args = [] + +if platform.system() == "Windows": + configure_env['CXXFLAGS'] = f'/MP{args.parallel}' + configure_env['CFLAGS'] = f'/MP{args.parallel}' +else: + build_args.extend(['-j', str(args.parallel)]) + +if args.headless: + configure_args.extend(['-DHEADLESS=1']) + +# Copy api out of the source tree and build it externally +with tempfile.TemporaryDirectory() as tempdir: + temp_api_base = Path(tempdir) / 'binaryninjaapi' + print(f'Copy {api_base} => {temp_api_base}') + shutil.copytree(api_base, temp_api_base) + + # Now try to build bundled out-of-tree projects the way customers would. + for f in external_project_cmake_files(): + example_base = Path(f).parent + + # Check for headless + if args.headless and contains_ui_dependency(f): + print(f'Skip {example_base} because it requires UI/Qt and --headless was specified') + continue + + with tempfile.TemporaryDirectory() as tempexdir: + temp_example_base = Path(tempexdir) / example_base.name + print(f'Copy {example_base} => {temp_example_base} at {datetime.now()}') + shutil.copytree(example_base, temp_example_base) + + if (temp_example_base / 'build').exists(): + shutil.rmtree(temp_example_base / 'build') + + try: + subprocess.check_call([cmake, '-B', 'build', f'-DBN_API_PATH={temp_api_base}'] + configure_args, + cwd=temp_example_base, env=configure_env) + subprocess.check_call([cmake, '--build', 'build'] + build_args, cwd=temp_example_base) + finally: + if (temp_example_base / 'build').exists(): + shutil.rmtree(temp_example_base / 'build') |
