Skip to content

Commit 61b0683

Browse files
committed
Split internal/external test build scripts.
Also make them a little more configurable.
1 parent 6bc36d9 commit 61b0683

3 files changed

Lines changed: 350 additions & 105 deletions

File tree

scripts/test_build_extern.py

Lines changed: 0 additions & 105 deletions
This file was deleted.

scripts/test_build_external.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import argparse
2+
import glob
3+
import os
4+
from pathlib import Path
5+
import platform
6+
import shutil
7+
import subprocess
8+
import sys
9+
import tempfile
10+
from datetime import datetime
11+
12+
parser = argparse.ArgumentParser(
13+
description='Test building the API repo and plugins out-of-tree (for CI checking of end-user workflow)'
14+
)
15+
parser.add_argument('--headless', default=False, action='store_true', help='Only include headless plugins')
16+
parser.add_argument('-j', '--parallel', default=4, help='Number of parallel jobs to tell cmake to run.')
17+
parser.add_argument('--bn-install-dir', default=os.environ.get('BN_INSTALL_DIR'),
18+
help='Path to a Binary Ninja installation. Defaults to BN_INSTALL_DIR.')
19+
parser.add_argument('--qt-install-dir', default=os.environ.get('QT_INSTALL_DIR'),
20+
help='Path to a Qt installation. Defaults to QT_INSTALL_DIR.')
21+
parser.add_argument('--qmake', default=os.environ.get('QMAKE'), help='Path to qmake. Defaults to QMAKE or PATH lookup.')
22+
parser.add_argument('--cmake', default=os.environ.get('CMAKE'), help='Path to cmake. Defaults to CMAKE or PATH lookup.')
23+
parser.add_argument('--cc', default=os.environ.get('CC'), help='C compiler to use. Defaults to CC or CMake platform default.')
24+
parser.add_argument('--cxx', default=os.environ.get('CXX'), help='C++ compiler to use. Defaults to CXX or CMake platform default.')
25+
parser.add_argument('--extra-project', action='append', default=[],
26+
help='Additional out-of-tree CMake project directory to build. May be specified multiple times.')
27+
args = parser.parse_args()
28+
29+
api_base = Path(__file__).parent.parent.absolute()
30+
31+
32+
def fail(message):
33+
print(f'ERROR: {message}')
34+
sys.exit(1)
35+
36+
37+
def prepend_path(env, path):
38+
env['PATH'] = f'{path}{os.pathsep}{env.get("PATH", "")}'
39+
40+
41+
def find_executable(name, explicit_path=None, env=None):
42+
if explicit_path:
43+
path = Path(explicit_path).expanduser()
44+
if path.is_file():
45+
return str(path)
46+
fail(f'{name} was specified as {path}, but that file does not exist.')
47+
48+
return shutil.which(name, path=(env or os.environ).get('PATH'))
49+
50+
51+
def run_tool(tool, args_for_tool, env=None):
52+
try:
53+
result = subprocess.run([tool] + args_for_tool, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
54+
text=True, env=env)
55+
return result.stdout.strip()
56+
except subprocess.CalledProcessError as e:
57+
output = e.stdout.strip() if e.stdout else ''
58+
fail(f'{tool} failed with exit code {e.returncode}.\n{output}')
59+
60+
61+
def print_tool(tool_name, tool_path, version_args, env=None):
62+
print(f'{tool_name}: {tool_path}')
63+
version = run_tool(tool_path, version_args, env=env)
64+
print(version)
65+
66+
67+
def configure_qt_env(env):
68+
qt_dir = Path(args.qt_install_dir).expanduser().absolute() if args.qt_install_dir else None
69+
if qt_dir:
70+
if not qt_dir.exists():
71+
fail(f'Qt directory was specified as {qt_dir}, but it does not exist.')
72+
qt_bin_dir = qt_dir / 'bin'
73+
if not qt_bin_dir.exists():
74+
fail(f'Qt directory {qt_dir} does not contain a bin directory.')
75+
prepend_path(env, qt_bin_dir)
76+
env.setdefault('CMAKE_PREFIX_PATH', str(qt_dir / 'lib' / 'cmake'))
77+
print(f'Qt directory: {qt_dir}')
78+
print(f'CMAKE_PREFIX_PATH: {env["CMAKE_PREFIX_PATH"]}')
79+
80+
if args.headless:
81+
return None
82+
83+
qmake = find_executable('qmake', args.qmake, env=env)
84+
if not qmake:
85+
fail('qmake not found. Install Qt and add its bin directory to PATH, set QT_INSTALL_DIR, or pass --qt-install-dir/--qmake.')
86+
87+
print_tool('qmake', qmake, ['--version'], env=env)
88+
qt_lib_path = run_tool(qmake, ['-query', 'QT_INSTALL_LIBS'], env=env)
89+
qt_cmake_path = str(Path(qt_lib_path) / 'cmake')
90+
env.setdefault('CMAKE_PREFIX_PATH', qt_cmake_path)
91+
print(f'Qt libraries: {qt_lib_path}')
92+
print(f'CMAKE_PREFIX_PATH: {env["CMAKE_PREFIX_PATH"]}')
93+
return qmake
94+
95+
96+
def check_binary_ninja_install(env):
97+
bn_install_dir = Path(args.bn_install_dir).expanduser().absolute() if args.bn_install_dir else None
98+
if not bn_install_dir:
99+
fail('Binary Ninja install directory was not specified. Set BN_INSTALL_DIR or pass --bn-install-dir.')
100+
if not bn_install_dir.exists():
101+
fail(f'Binary Ninja install directory {bn_install_dir} does not exist.')
102+
103+
env['BN_INSTALL_DIR'] = str(bn_install_dir)
104+
if platform.system() == 'Darwin':
105+
core_candidates = [bn_install_dir / 'Contents' / 'MacOS' / 'libbinaryninjacore.dylib']
106+
ui_candidates = [bn_install_dir / 'Contents' / 'MacOS' / 'libbinaryninjaui.dylib']
107+
elif platform.system() == 'Windows':
108+
core_candidates = [bn_install_dir / 'binaryninjacore.dll']
109+
ui_candidates = [bn_install_dir / 'binaryninjaui.dll']
110+
else:
111+
core_candidates = [bn_install_dir / 'libbinaryninjacore.so.1', bn_install_dir / 'libbinaryninjacore.so']
112+
ui_candidates = [bn_install_dir / 'libbinaryninjaui.so.1', bn_install_dir / 'libbinaryninjaui.so']
113+
114+
core = next((p for p in core_candidates if p.exists()), None)
115+
if not core:
116+
fail(f'Binary Ninja Core was not found in {bn_install_dir}. Checked: {", ".join(str(p) for p in core_candidates)}')
117+
print(f'Binary Ninja install: {bn_install_dir}')
118+
print(f'Binary Ninja Core: {core}')
119+
120+
ui = next((p for p in ui_candidates if p.exists()), None)
121+
if args.headless:
122+
print('Binary Ninja UI: skipped because --headless was specified')
123+
elif ui:
124+
print(f'Binary Ninja UI: {ui}')
125+
else:
126+
fail(f'Binary Ninja UI was not found in {bn_install_dir}. Checked: {", ".join(str(p) for p in ui_candidates)}')
127+
128+
129+
def contains_ui_dependency(cmake_lists):
130+
with open(cmake_lists, 'r', encoding='utf-8') as cmake_file:
131+
contents = cmake_file.read()
132+
return 'binaryninjaui' in contents or 'Qt6::' in contents or 'find_package(Qt6' in contents
133+
134+
135+
def external_project_cmake_files():
136+
cmake_files = []
137+
138+
# The examples are the supported customer-facing out-of-tree CMake projects
139+
for f in glob.glob(str(api_base / 'examples' / '**' / 'CMakeLists.txt'), recursive=True):
140+
cmake_path = Path(f)
141+
if cmake_path.parent == api_base / 'examples':
142+
continue
143+
cmake_files.append(cmake_path)
144+
145+
for project in args.extra_project:
146+
project_path = Path(project).expanduser()
147+
if not project_path.is_absolute():
148+
project_path = api_base / project_path
149+
cmake_path = project_path / 'CMakeLists.txt'
150+
if not cmake_path.exists():
151+
fail(f'Extra project {project_path} does not contain a CMakeLists.txt file.')
152+
cmake_files.append(cmake_path)
153+
154+
return sorted(cmake_files)
155+
156+
157+
configure_env = os.environ.copy()
158+
configure_qt_env(configure_env)
159+
check_binary_ninja_install(configure_env)
160+
161+
if args.cc:
162+
configure_env['CC'] = args.cc
163+
if args.cxx:
164+
configure_env['CXX'] = args.cxx
165+
166+
cmake = find_executable('cmake', args.cmake, env=configure_env)
167+
if not cmake:
168+
fail('cmake not found. Install CMake and add it to PATH, set CMAKE, or pass --cmake.')
169+
print_tool('cmake', cmake, ['--version'], env=configure_env)
170+
171+
print(f'C compiler: {configure_env.get("CC", "CMake platform default")}')
172+
print(f'C++ compiler: {configure_env.get("CXX", "CMake platform default")}')
173+
174+
configure_args = []
175+
build_args = []
176+
177+
if platform.system() == "Windows":
178+
configure_env['CXXFLAGS'] = f'/MP{args.parallel}'
179+
configure_env['CFLAGS'] = f'/MP{args.parallel}'
180+
else:
181+
build_args.extend(['-j', str(args.parallel)])
182+
183+
if args.headless:
184+
configure_args.extend(['-DHEADLESS=1'])
185+
186+
# Copy api out of the source tree and build it externally
187+
with tempfile.TemporaryDirectory() as tempdir:
188+
temp_api_base = Path(tempdir) / 'binaryninjaapi'
189+
print(f'Copy {api_base} => {temp_api_base}')
190+
shutil.copytree(api_base, temp_api_base)
191+
192+
# Now try to build bundled out-of-tree projects the way customers would.
193+
for f in external_project_cmake_files():
194+
example_base = Path(f).parent
195+
196+
# Check for headless
197+
if args.headless and contains_ui_dependency(f):
198+
print(f'Skip {example_base} because it requires UI/Qt and --headless was specified')
199+
continue
200+
201+
with tempfile.TemporaryDirectory() as tempexdir:
202+
temp_example_base = Path(tempexdir) / example_base.name
203+
print(f'Copy {example_base} => {temp_example_base} at {datetime.now()}')
204+
shutil.copytree(example_base, temp_example_base)
205+
206+
if (temp_example_base / 'build').exists():
207+
shutil.rmtree(temp_example_base / 'build')
208+
209+
try:
210+
subprocess.check_call([cmake, '-B', 'build', f'-DBN_API_PATH={temp_api_base}'] + configure_args,
211+
cwd=temp_example_base, env=configure_env)
212+
subprocess.check_call([cmake, '--build', 'build'] + build_args, cwd=temp_example_base)
213+
finally:
214+
if (temp_example_base / 'build').exists():
215+
shutil.rmtree(temp_example_base / 'build')

0 commit comments

Comments
 (0)