blob: c955032641e1444ca1336d79af2d51158d7ccd0d [file] [edit]
#!/usr/bin/env python3
# Copyright 2026 The Abseil Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Hermetic multi-stage CMake build and test runner for Bazel RBE actions."""
import argparse
import collections
from concurrent import futures
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
# Fixed timestamps (UTC) used to keep CMake/Ninja build trees deterministic
# across isolated Bazel RBE actions:
# - STUB_MTIME (2000-01-01): Applied to stub sources during configure and
# test/header sources during test compilation.
# - BUILD_MTIME (2020-01-01): Applied to configured build files, compiled
# object files, and linked libraries.
STUB_MTIME = 946684800
BUILD_MTIME = 1577836800
WORK_ROOT_TOKEN = b"@CMAKE_WORK_ROOT@"
_GTEST_CONFIG_TEMPLATE = """\
include(CMakeFindDependencyMacro)
find_dependency(Threads)
get_filename_component(_GTEST_PREFIX "${{CMAKE_CURRENT_LIST_DIR}}/../../.." ABSOLUTE)
set(GTEST_FOUND TRUE)
set(GTest_FOUND TRUE)
set(GTEST_INCLUDE_DIRS "${{_GTEST_PREFIX}}/include")
if(NOT TARGET GTest::gtest)
add_library(GTest::gtest {lib_kind} IMPORTED GLOBAL)
set_target_properties(GTest::gtest PROPERTIES
IMPORTED_LOCATION "${{_GTEST_PREFIX}}/lib/libgtest.{lib_ext}"
INTERFACE_INCLUDE_DIRECTORIES "${{_GTEST_PREFIX}}/include"
INTERFACE_LINK_LIBRARIES "Threads::Threads"
)
endif()
if(NOT TARGET GTest::gmock)
add_library(GTest::gmock {lib_kind} IMPORTED GLOBAL)
set_target_properties(GTest::gmock PROPERTIES
IMPORTED_LOCATION "${{_GTEST_PREFIX}}/lib/libgmock.{lib_ext}"
INTERFACE_INCLUDE_DIRECTORIES "${{_GTEST_PREFIX}}/include"
INTERFACE_LINK_LIBRARIES "GTest::gtest;Threads::Threads"
)
endif()
if(NOT TARGET GTest::gtest_main)
add_library(GTest::gtest_main {lib_kind} IMPORTED GLOBAL)
set_target_properties(GTest::gtest_main PROPERTIES
IMPORTED_LOCATION "${{_GTEST_PREFIX}}/lib/libgtest_main.{lib_ext}"
INTERFACE_INCLUDE_DIRECTORIES "${{_GTEST_PREFIX}}/include"
INTERFACE_LINK_LIBRARIES "GTest::gmock;GTest::gtest;Threads::Threads"
)
endif()
if(NOT TARGET GTest::gmock_main)
add_library(GTest::gmock_main {lib_kind} IMPORTED GLOBAL)
set_target_properties(GTest::gmock_main PROPERTIES
IMPORTED_LOCATION "${{_GTEST_PREFIX}}/lib/libgmock_main.{lib_ext}"
INTERFACE_INCLUDE_DIRECTORIES "${{_GTEST_PREFIX}}/include"
INTERFACE_LINK_LIBRARIES "GTest::gmock;GTest::gtest;Threads::Threads"
)
endif()
foreach(_t gtest gmock gtest_main gmock_main)
if(NOT TARGET ${{_t}})
add_library(${{_t}} ALIAS GTest::${{_t}})
endif()
endforeach()
"""
def _resolve_tool(path):
"""Returns the absolute path to `path` if it exists locally."""
if path and os.path.exists(path):
return os.path.abspath(path)
return path
def _resolve_cxx(cc, cxx):
"""Resolves the C++ compiler executable from `cc` and `cxx`."""
cxx = _resolve_tool(cxx)
if cxx.endswith("/gcc") and os.path.exists(cxx[:-3] + "g++"):
return cxx[:-3] + "g++"
if cxx.endswith("/clang") and os.path.exists(cxx + "++"):
return cxx + "++"
if not cxx and cc:
return _resolve_cxx(cc, cc)
return cxx
def _make_env(args, work_root=None, extra_env=None):
"""Constructs the environment dictionary for CMake, Ninja, and CTest."""
env = dict(os.environ)
cc = _resolve_tool(args.cc)
cxx = _resolve_cxx(cc, args.cxx)
path_dirs = []
for tool in (cc, cxx):
if tool and os.path.dirname(tool):
path_dirs.append(os.path.dirname(tool))
path_dirs.extend([
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
])
if env.get("PATH"):
path_dirs.append(env["PATH"])
env["PATH"] = ":".join(path_dirs)
env["CC"] = cc
env["CXX"] = cxx
base_flags = ["-Werror", "-ffile-prefix-map=../src=."]
cflags = list(args.cflag) + base_flags
cxxflags = list(args.cxxflag) + base_flags
ldflags = list(args.ldflag)
env["CFLAGS"] = " ".join(cflags)
env["CXXFLAGS"] = " ".join(cxxflags)
if ldflags:
env["LDFLAGS"] = " ".join(ldflags)
env["CMAKE_GENERATOR"] = "Ninja"
jobs = str(os.cpu_count() or 2)
env["CMAKE_BUILD_PARALLEL_LEVEL"] = jobs
env["CTEST_PARALLEL_LEVEL"] = jobs
symbolizer = (
os.path.join(os.path.dirname(cc), "llvm-symbolizer") if cc else ""
)
if symbolizer and os.path.exists(symbolizer):
for var in (
"ASAN_SYMBOLIZER_PATH",
"UBSAN_SYMBOLIZER_PATH",
"MSAN_SYMBOLIZER_PATH",
"TSAN_SYMBOLIZER_PATH",
):
env.setdefault(var, symbolizer)
env.setdefault("UBSAN_OPTIONS", "print_stacktrace=1")
env.setdefault("GTEST_INSTALL_FAILURE_SIGNAL_HANDLER", "1")
ld_paths = []
if work_root:
ld_paths.extend([
os.path.join(work_root, "deps", "lib"),
os.path.join(work_root, "build", "lib"),
os.path.join(work_root, "build", "bin"),
])
ninja_bin = shutil.which("ninja", path=env.get("PATH"))
if ninja_bin:
p = subprocess.run(
[ninja_bin, "--version"], env=env, capture_output=True, check=False
)
if p.returncode != 0:
for ld_so, sys_lib in (
(
"/lib64/ld-linux-x86-64.so.2",
"/usr/lib/x86_64-linux-gnu:/lib/x86_64-linux-gnu",
),
(
"/lib/ld-linux-aarch64.so.1",
"/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu",
),
):
if os.path.exists(ld_so):
bin_dir = os.path.join(work_root, "_bin")
os.makedirs(bin_dir, exist_ok=True)
wrapper = os.path.join(bin_dir, "ninja")
with open(wrapper, "w") as wf:
wf.write(
f"#!/bin/sh\nexec {ld_so} --library-path {sys_lib}"
f' {ninja_bin} "$@"\n'
)
os.chmod(wrapper, 0o755)
env["PATH"] = bin_dir + ":" + env["PATH"]
break
if env.get("LD_LIBRARY_PATH"):
ld_paths.append(env["LD_LIBRARY_PATH"])
if ld_paths:
env["LD_LIBRARY_PATH"] = ":".join(ld_paths)
if extra_env:
env.update(extra_env)
return env
def _copy_mapped_files(exec_root, dst_root, mappings, mtime=None):
"""Copies `<src>=<dst>` file mappings from `exec_root` into `dst_root`."""
for item in mappings:
src_rel, dst_rel = item.split("=", 1)
full_src = os.path.join(exec_root, src_rel)
full_dst = os.path.join(dst_root, dst_rel)
if os.path.exists(full_src):
os.makedirs(os.path.dirname(full_dst), exist_ok=True)
shutil.copyfile(full_src, full_dst)
if mtime is not None:
os.utime(full_dst, (mtime, mtime))
else:
os.utime(full_dst, None)
def _replace_in_tree(root_dir, old_bytes, new_bytes, mtime=BUILD_MTIME):
"""Replaces `old_bytes` with `new_bytes` in non-binary files under `root_dir`."""
if not os.path.exists(root_dir):
return
for root, _, files in os.walk(root_dir):
for f in files:
if f in ("build.ninja", "rules.ninja") or f.endswith((".o", ".a", ".so")):
continue
fpath = os.path.join(root, f)
if os.path.islink(fpath):
continue
try:
with open(fpath, "rb") as rf:
data = rf.read()
except OSError:
continue
if b"\x00" in data[:4096] or old_bytes not in data:
continue
with open(fpath, "wb") as wf:
wf.write(data.replace(old_bytes, new_bytes))
if mtime is not None:
os.utime(fpath, (mtime, mtime))
def _set_tree_mtime(root_dir, mtime=BUILD_MTIME):
"""Sets the modification time of all regular files under `root_dir`."""
if not os.path.exists(root_dir):
return
for root, _, files in os.walk(root_dir):
for f in files:
fpath = os.path.join(root, f)
if not os.path.islink(fpath):
os.utime(fpath, (mtime, mtime))
def _tar_create(out_tar, base_dir, items, mtime=BUILD_MTIME):
"""Creates a deterministic tar archive with normalized metadata and ordering."""
subprocess.check_call([
"tar",
"--sort=name",
f"--mtime=@{mtime}",
"--owner=0",
"--group=0",
"--numeric-owner",
"-cf",
out_tar,
"-C",
base_dir,
*items,
])
def _cmd_build_gtest(args):
"""Compiles and packages GoogleTest and GoogleMock with a GTestConfig.cmake."""
exec_root = os.getcwd()
out_tar = os.path.abspath(args.out_tar)
cc = _resolve_tool(args.cc)
cxx = _resolve_cxx(cc, args.cxx)
ar = _resolve_tool(args.ar) or "ar"
opt_flag = "-O2" if args.build_type == "Release" else "-g"
with tempfile.TemporaryDirectory(prefix="cmake_gtest_") as work_root:
src_root = os.path.join(work_root, "src")
obj_root = os.path.join(work_root, "objs")
prefix_root = os.path.join(work_root, "prefix")
inc_dir = os.path.join(prefix_root, "include")
lib_dir = os.path.join(prefix_root, "lib")
cmake_dir = os.path.join(lib_dir, "cmake", "GTest")
os.makedirs(obj_root, exist_ok=True)
os.makedirs(inc_dir, exist_ok=True)
os.makedirs(cmake_dir, exist_ok=True)
_copy_mapped_files(exec_root, src_root, args.src_map, mtime=STUB_MTIME)
for sub in ("googletest", "googlemock"):
src_inc = os.path.join(src_root, sub, "include")
if os.path.isdir(src_inc):
shutil.copytree(src_inc, inc_dir, dirs_exist_ok=True)
gtest_src_dir = os.path.join(src_root, "googletest", "src")
gmock_src_dir = os.path.join(src_root, "googlemock", "src")
gtest_ccs = sorted([
os.path.join(gtest_src_dir, f)
for f in os.listdir(gtest_src_dir)
if f.endswith(".cc") and f not in ("gtest-all.cc", "gtest_main.cc")
])
gmock_ccs = sorted([
os.path.join(gmock_src_dir, f)
for f in os.listdir(gmock_src_dir)
if f.endswith(".cc") and f not in ("gmock-all.cc", "gmock_main.cc")
])
main_cc = os.path.join(gmock_src_dir, "gmock_main.cc")
if not os.path.exists(main_cc):
main_cc = os.path.join(gtest_src_dir, "gtest_main.cc")
include_flags = [
"-I",
os.path.join(src_root, "googletest", "include"),
"-I",
os.path.join(src_root, "googletest"),
"-I",
os.path.join(src_root, "googlemock", "include"),
"-I",
os.path.join(src_root, "googlemock"),
]
env = _make_env(args)
def compile_one(cc_file):
obj_file = os.path.join(obj_root, os.path.basename(cc_file) + ".o")
cmd = [
cxx,
*args.cxxflag,
"-w",
"-fPIC",
opt_flag,
"-pthread",
f"-ffile-prefix-map={work_root}=.",
*include_flags,
"-c",
cc_file,
"-o",
obj_file,
]
subprocess.check_call(cmd, env=env)
return obj_file
all_ccs = gtest_ccs + gmock_ccs + [main_cc]
with futures.ThreadPoolExecutor(max_workers=os.cpu_count() or 2) as pool:
compiled = dict(zip(all_ccs, pool.map(compile_one, all_ccs)))
gtest_objs = [compiled[c] for c in gtest_ccs]
gmock_objs = [compiled[c] for c in gmock_ccs]
main_obj = compiled[main_cc]
if args.shared == "ON":
lib_kind = "SHARED"
lib_ext = "so"
gtest_so = os.path.join(lib_dir, "libgtest.so")
gmock_so = os.path.join(lib_dir, "libgmock.so")
gtest_main_so = os.path.join(lib_dir, "libgtest_main.so")
gmock_main_so = os.path.join(lib_dir, "libgmock_main.so")
subprocess.check_call(
[
cxx,
"-shared",
"-Wl,-soname,libgtest.so",
"-Wl,-rpath,$ORIGIN",
"-pthread",
*gtest_objs,
*args.ldflag,
"-o",
gtest_so,
],
env=env,
)
subprocess.check_call(
[
cxx,
"-shared",
"-Wl,-soname,libgmock.so",
"-Wl,-rpath,$ORIGIN",
"-pthread",
*gmock_objs,
gtest_so,
*args.ldflag,
"-o",
gmock_so,
],
env=env,
)
subprocess.check_call(
[
cxx,
"-shared",
"-Wl,-soname,libgtest_main.so",
"-Wl,-rpath,$ORIGIN",
"-pthread",
main_obj,
gmock_so,
gtest_so,
*args.ldflag,
"-o",
gtest_main_so,
],
env=env,
)
subprocess.check_call(
[
cxx,
"-shared",
"-Wl,-soname,libgmock_main.so",
"-Wl,-rpath,$ORIGIN",
"-pthread",
main_obj,
gmock_so,
gtest_so,
*args.ldflag,
"-o",
gmock_main_so,
],
env=env,
)
else:
lib_kind = "STATIC"
lib_ext = "a"
subprocess.check_call(
[ar, "rcs", os.path.join(lib_dir, "libgtest.a"), *gtest_objs],
env=env,
)
subprocess.check_call(
[ar, "rcs", os.path.join(lib_dir, "libgmock.a"), *gmock_objs],
env=env,
)
subprocess.check_call(
[ar, "rcs", os.path.join(lib_dir, "libgtest_main.a"), main_obj],
env=env,
)
subprocess.check_call(
[ar, "rcs", os.path.join(lib_dir, "libgmock_main.a"), main_obj],
env=env,
)
with open(os.path.join(cmake_dir, "GTestConfig.cmake"), "w") as cf:
cf.write(
_GTEST_CONFIG_TEMPLATE.format(lib_kind=lib_kind, lib_ext=lib_ext)
)
_set_tree_mtime(prefix_root, BUILD_MTIME)
_tar_create(out_tar, prefix_root, ["include", "lib"])
def _cmd_configure(args):
"""Runs CMake configure and emits object, library, and test build specs."""
exec_root = os.getcwd()
objs_spec_out = os.path.abspath(args.objs_spec_out)
libs_spec_out = os.path.abspath(args.libs_spec_out)
tests_spec_out = os.path.abspath(args.tests_spec_out)
install_build_out = os.path.abspath(args.install_build_out)
with tempfile.TemporaryDirectory(prefix="cmake_cfg_") as work_root:
src_root = os.path.join(work_root, "src")
build_dir = os.path.join(work_root, "build")
deps_dir = os.path.join(work_root, "deps")
install_dir = os.path.join(work_root, "install")
os.makedirs(src_root, exist_ok=True)
os.makedirs(build_dir, exist_ok=True)
os.makedirs(deps_dir, exist_ok=True)
for dep_tar in args.dep_tar:
subprocess.check_call(
["tar", "-xf", os.path.join(exec_root, dep_tar), "-C", deps_dir]
)
_replace_in_tree(deps_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME)
_copy_mapped_files(exec_root, src_root, args.src_map, mtime=STUB_MTIME)
# Create 0-byte stubs for source and header files referenced in
# CMakeLists.txt and *.cmake so CMake generates the full Ninja/CTest graph
# without depending on any project .cc or .h file contents.
absl_root = os.path.join(src_root, "absl")
for root, _, files in os.walk(src_root):
for f in files:
fpath = os.path.join(root, f)
os.utime(fpath, (STUB_MTIME, STUB_MTIME))
if f == "CMakeLists.txt" or f.endswith(".cmake"):
with open(fpath, errors="replace") as cf:
text = cf.read()
base_dir = (
absl_root
if (f == "AbseilDll.cmake" and os.path.isdir(absl_root))
else root
)
for token in re.findall(
r'["\s(]([A-Za-z0-9_./-]+\.(?:cc|c|h|inc|hpp))["\s)]', text
):
candidate = os.path.join(base_dir, token)
if not os.path.exists(candidate):
os.makedirs(os.path.dirname(candidate), exist_ok=True)
open(candidate, "w").close()
os.utime(candidate, (STUB_MTIME, STUB_MTIME))
env = _make_env(args, work_root=work_root)
jobs = env["CMAKE_BUILD_PARALLEL_LEVEL"]
cmake_cmd = [
"cmake",
"-Wno-unused-cli",
"-S",
src_root,
"-B",
build_dir,
"-GNinja",
"-DCMAKE_SUPPRESS_REGENERATION=ON",
"-DCMAKE_BUILD_RPATH_USE_ORIGIN=ON",
"-DCMAKE_INSTALL_LIBDIR=lib",
f"-DCMAKE_INSTALL_PREFIX={install_dir}",
f"-DCMAKE_PREFIX_PATH={deps_dir}",
f"-DCMAKE_BUILD_TYPE={args.build_type}",
"-DCMAKE_C_FLAGS_RELEASE=-O2 -DNDEBUG",
"-DCMAKE_CXX_FLAGS_RELEASE=-O2 -DNDEBUG",
"-DCMAKE_EXE_LINKER_FLAGS_DEBUG=",
"-DCMAKE_SHARED_LINKER_FLAGS_DEBUG=",
f"-DCMAKE_CXX_STANDARD={args.cxx_std}",
"-DCMAKE_CXX_STANDARD_REQUIRED=ON",
f"-DBUILD_SHARED_LIBS={args.shared}",
"-DBUILD_TESTING=ON",
f"-DCMAKE_JOB_POOLS=link={jobs}",
"-DCMAKE_JOB_POOL_LINK=link",
*args.cmake_arg,
]
subprocess.check_call(cmake_cmd, env=env)
def _relativize_cmd(text):
text = re.sub(r"\s+-MD\s+-MT\s+\S+\s+-MF\s+\S+", "", text)
text = re.sub(r"\s*(?:-Xlinker\s+|-Wl,)--dependency-file=\S+", "", text)
text = text.replace(work_root + "/src", "../src")
text = text.replace(work_root + "/deps", "../deps")
text = text.replace(work_root, WORK_ROOT_TOKEN.decode())
return text
targets_out = subprocess.check_output(
["ninja", "-C", build_dir, "-t", "targets", "all"],
text=True,
env=env,
)
lib_cmake_names = set()
lib_outs = set()
exe_cmake_to_out = {}
for line in targets_out.splitlines():
if ": " not in line:
continue
out, rule = [x.strip() for x in line.split(": ", 1)]
m_lib = re.search(
r"_(?:STATIC|SHARED)_LIBRARY_LINKER__(.+?)_(?:Release|Debug|RelWithDebInfo|MinSizeRel)\b",
rule,
)
if m_lib:
lib_cmake_names.add(m_lib.group(1))
lib_outs.add(out)
elif "CMAKE_SYMLINK_LIBRARY" in rule:
lib_outs.add(out)
m_exe = re.search(
r"_EXECUTABLE_LINKER__(.+?)_(?:Release|Debug|RelWithDebInfo|MinSizeRel)\b",
rule,
)
if m_exe:
exe_cmake_to_out[m_exe.group(1)] = out
compdb = json.loads(
subprocess.check_output(
["ninja", "-C", build_dir, "-t", "compdb"], text=True, env=env
)
)
cmd_by_out = {}
cmd_to_lib_out = {}
lib_objs_by_src = collections.defaultdict(set)
exe_objs = collections.defaultdict(list)
for entry in compdb:
out = entry["output"]
raw_cmd = entry["command"]
if not raw_cmd:
continue
cmd = _relativize_cmd(raw_cmd)
cmd_by_out[out] = cmd
if out in lib_outs:
cmd_to_lib_out[cmd] = out
if out.endswith(".o"):
in_file = _relativize_cmd(entry["file"])
if in_file.startswith("../src/"):
rel_cc = in_file[len("../src/") :]
m = re.search(r"CMakeFiles/([^/]+)\.dir/", out)
if m:
tname = m.group(1)
if tname in lib_cmake_names:
lib_objs_by_src[rel_cc].add((out, cmd))
if tname in exe_cmake_to_out:
exe_objs[tname].append((rel_cc, out, cmd))
ordered_lib_cmds = []
if lib_outs:
raw_lib_cmds = subprocess.check_output(
["ninja", "-C", build_dir, "-t", "commands", *sorted(lib_outs)],
text=True,
env=env,
).splitlines()
seen_cmds = set()
for raw_cmd in raw_lib_cmds:
rel_cmd = _relativize_cmd(raw_cmd)
if rel_cmd in cmd_to_lib_out and rel_cmd not in seen_cmds:
seen_cmds.add(rel_cmd)
ordered_lib_cmds.append([cmd_to_lib_out[rel_cmd], rel_cmd])
def _direct_libs(out):
res = set()
for tok in cmd_by_out.get(out, "").split():
if tok in lib_outs:
res.add(tok)
if tok.endswith(".so"):
for l in lib_outs:
if l.startswith(tok + "."):
res.add(l)
elif ".so." in tok:
base = tok.split(".so.")[0] + ".so"
if base in lib_outs:
res.add(base)
return res
trans_cache = {}
def _trans_libs(out):
if out in trans_cache:
return trans_cache[out]
res = set(_direct_libs(out))
trans_cache[out] = res
for dlib in list(res):
res |= _trans_libs(dlib)
return res
ctest_by_id = collections.defaultdict(list)
for root, _, files in os.walk(build_dir):
if "CTestTestfile.cmake" in files:
cpath = os.path.join(root, "CTestTestfile.cmake")
with open(cpath) as cf:
ctext = cf.read()
ctext = re.sub(r'\s*_BACKTRACE_TRIPLES\s+"[^"]*"', "", ctext)
ctext = ctext.replace(work_root, WORK_ROOT_TOKEN.decode())
for line in ctext.splitlines():
sline = line.strip()
m = re.match(r"^(?:add_test|set_tests_properties)\((\S+)\b", sline)
if m:
ctest_by_id[m.group(1)].append(sline)
exes_by_name = {}
src_to_exes = collections.defaultdict(set)
for tname, exe_out in sorted(exe_cmake_to_out.items()):
exe_cmd = cmd_by_out.get(exe_out, "")
req_libs = sorted(_trans_libs(exe_out))
obj_cmds = [
[o, c] for o, c in sorted(set((o, c) for _, o, c in exe_objs[tname]))
]
ctest_lines = []
for cid in sorted(ctest_by_id.keys()):
if cid == tname or cid.startswith(tname + "_shard_"):
ctest_lines.extend(ctest_by_id[cid])
exes_by_name[tname] = {
"cmake_name": tname,
"ctest_lines": ctest_lines,
"exe_cmd": exe_cmd,
"exe_out": exe_out,
"obj_cmds": obj_cmds,
"req_libs": req_libs,
}
for rel_cc, _, _ in exe_objs[tname]:
src_to_exes[rel_cc].add(tname)
objs_json = {
rel_cc: [[o, c] for o, c in sorted(pairs)]
for rel_cc, pairs in sorted(lib_objs_by_src.items())
}
with open(objs_spec_out, "w") as of:
json.dump(objs_json, of, indent=2, sort_keys=True)
tests_json = {
"exes": exes_by_name,
"src_to_exes": {
rel_cc: sorted(tnames)
for rel_cc, tnames in sorted(src_to_exes.items())
},
}
with open(tests_spec_out, "w") as tf:
json.dump(tests_json, tf, indent=2, sort_keys=True)
with open(libs_spec_out, "w") as lf:
json.dump(
{"ordered_lib_cmds": ordered_lib_cmds}, lf, indent=2, sort_keys=True
)
for nfile in (
os.path.join(build_dir, "build.ninja"),
os.path.join(build_dir, "CMakeFiles", "rules.ninja"),
os.path.join(build_dir, ".ninja_deps"),
os.path.join(build_dir, ".ninja_log"),
):
if os.path.exists(nfile):
os.remove(nfile)
for root, _, files in os.walk(build_dir):
for f in files:
if f == "cmake_install.cmake":
p = os.path.join(root, f)
with open(p) as cf:
ctext = cf.read()
ctext = ctext.replace(f"$ORIGIN:{work_root}/deps/lib", "$ORIGIN")
ctext = ctext.replace(work_root + "/deps", "../deps")
with open(p, "w") as cf:
cf.write(ctext)
_replace_in_tree(
build_dir, work_root.encode(), WORK_ROOT_TOKEN, BUILD_MTIME
)
_set_tree_mtime(build_dir, BUILD_MTIME)
_tar_create(install_build_out, work_root, ["src", "build"])
def _cmd_slice_obj_spec(args):
"""Extracts the CMake object compile spec for a single cc_library."""
with open(args.objs_spec) as pf:
all_objs = json.load(pf)
cmds = []
seen = set()
for rel_cc in sorted(args.cc_rel):
for obj_out, cmd in all_objs.get(rel_cc, []):
if obj_out not in seen:
seen.add(obj_out)
cmds.append([obj_out, cmd])
with open(args.out, "w") as of:
json.dump({"obj_cmds": cmds}, of, indent=2, sort_keys=True)
def _cmd_compile_objs(args):
"""Compiles CMake library object files for a single cc_library."""
exec_root = os.getcwd()
out_tar = os.path.abspath(args.out_tar)
with open(args.obj_spec) as sf:
obj_cmds = json.load(sf).get("obj_cmds", [])
with tempfile.TemporaryDirectory(prefix="cmake_objs_") as work_root:
src_root = os.path.join(work_root, "src")
build_dir = os.path.join(work_root, "build")
deps_dir = os.path.join(work_root, "deps")
os.makedirs(src_root, exist_ok=True)
os.makedirs(build_dir, exist_ok=True)
if any("../deps" in cmd for _, cmd in obj_cmds):
os.makedirs(deps_dir, exist_ok=True)
for dep_tar in args.dep_tar:
subprocess.check_call(
["tar", "-xf", os.path.join(exec_root, dep_tar), "-C", deps_dir]
)
_replace_in_tree(
deps_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME
)
_copy_mapped_files(exec_root, src_root, args.src_map, mtime=None)
env = _make_env(args, work_root=work_root)
def _compile_one(item):
obj_out, cmd = item
full_out = os.path.join(build_dir, obj_out)
os.makedirs(os.path.dirname(full_out), exist_ok=True)
subprocess.check_call(cmd, shell=True, cwd=build_dir, env=env)
os.utime(full_out, (BUILD_MTIME, BUILD_MTIME))
return obj_out
if obj_cmds:
with futures.ThreadPoolExecutor(max_workers=os.cpu_count() or 2) as pool:
sorted_objs = sorted(pool.map(_compile_one, obj_cmds))
_tar_create(out_tar, build_dir, sorted_objs)
else:
subprocess.check_call(["tar", "-cf", out_tar, "-T", "/dev/null"])
def _cmd_link_libs(args):
"""Links all CMake libraries from per-library object archives."""
exec_root = os.getcwd()
out_tar = os.path.abspath(args.out_tar)
with open(args.libs_spec) as lf:
ordered_lib_cmds = json.load(lf).get("ordered_lib_cmds", [])
with tempfile.TemporaryDirectory(prefix="cmake_libs_") as work_root:
build_dir = os.path.join(work_root, "build")
deps_dir = os.path.join(work_root, "deps")
os.makedirs(build_dir, exist_ok=True)
os.makedirs(deps_dir, exist_ok=True)
for dep_tar in args.dep_tar:
subprocess.check_call(
["tar", "-xf", os.path.join(exec_root, dep_tar), "-C", deps_dir]
)
_replace_in_tree(deps_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME)
for rel_tar in args.obj_tar:
full_tar = os.path.join(exec_root, rel_tar)
subprocess.check_call(["tar", "-xf", full_tar, "-C", build_dir])
env = _make_env(args, work_root=work_root)
for lib_out, cmd in ordered_lib_cmds:
full_out = os.path.join(build_dir, lib_out)
os.makedirs(os.path.dirname(full_out), exist_ok=True)
subprocess.check_call(cmd, shell=True, cwd=build_dir, env=env)
if os.path.exists(full_out) and not os.path.islink(full_out):
os.utime(full_out, (BUILD_MTIME, BUILD_MTIME))
# Remove intermediate .o files so the library archive only contains linked
# libraries and dependencies.
for root, _, files in os.walk(build_dir):
for f in files:
if f.endswith(".o"):
os.remove(os.path.join(root, f))
_replace_in_tree(
build_dir, work_root.encode(), WORK_ROOT_TOKEN, BUILD_MTIME
)
_replace_in_tree(deps_dir, work_root.encode(), WORK_ROOT_TOKEN, BUILD_MTIME)
_set_tree_mtime(build_dir, BUILD_MTIME)
_set_tree_mtime(deps_dir, BUILD_MTIME)
_tar_create(out_tar, work_root, ["build", "deps"])
def _cmd_slice_test_spec(args):
"""Extracts the CMake test build spec and required libraries for a cc_test."""
with open(args.tests_spec) as tf:
tests_spec = json.load(tf)
exes_by_name = tests_spec.get("exes", {})
src_to_exes = tests_spec.get("src_to_exes", {})
matched_names = set()
for rel_cc in args.cc_rel:
matched_names.update(src_to_exes.get(rel_cc, []))
exes = []
req_lib_set = set()
for name in sorted(matched_names):
exe = dict(exes_by_name[name])
req_lib_set.update(exe.pop("req_libs", []))
exes.append(exe)
with open(args.spec_out, "w") as sf:
json.dump({"exes": exes}, sf, indent=2, sort_keys=True)
with open(args.req_libs_out, "w") as rf:
json.dump(
{"has_exes": bool(exes), "req_libs": sorted(req_lib_set)},
rf,
indent=2,
sort_keys=True,
)
def _cmd_slice_test_libs(args):
"""Packages only the linked CMake libraries required by a single cc_test."""
out_tar = os.path.abspath(args.out_tar)
with open(args.req_libs_spec) as rf:
req_info = json.load(rf)
has_exes = req_info.get("has_exes", False)
req_libs = req_info.get("req_libs", [])
with tarfile.open(out_tar, "w") as out_tf:
if has_exes:
wanted = set("build/" + l for l in req_libs)
with tarfile.open(args.libs_tar, "r") as in_tf:
members = [
m
for m in in_tf.getmembers()
if m.name == "deps"
or m.name.startswith("deps/")
or m.name in wanted
]
members.sort(key=lambda m: m.name)
for member in members:
fobj = in_tf.extractfile(member) if member.isfile() else None
member.mtime = BUILD_MTIME
member.uid = 0
member.gid = 0
member.uname = ""
member.gname = ""
out_tf.addfile(member, fobj)
def _cmd_package(args):
"""Runs `cmake --install` and packages the installed prefix archive."""
exec_root = os.getcwd()
out_tar = os.path.abspath(args.out_tar)
install_build_tar = os.path.join(exec_root, args.install_build_tar)
libs_tar = os.path.join(exec_root, args.libs_tar)
with tempfile.TemporaryDirectory(prefix="cmake_pkg_") as work_root:
subprocess.check_call(["tar", "-xf", install_build_tar, "-C", work_root])
subprocess.check_call(["tar", "-xf", libs_tar, "-C", work_root])
src_root = os.path.join(work_root, "src")
build_dir = os.path.join(work_root, "build")
deps_dir = os.path.join(work_root, "deps")
install_dir = os.path.join(work_root, "install")
_replace_in_tree(
build_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME
)
_replace_in_tree(deps_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME)
_copy_mapped_files(exec_root, src_root, args.hdr_map, mtime=STUB_MTIME)
env = _make_env(args, work_root=work_root)
subprocess.check_call(
["cmake", "--install", build_dir, "--prefix", install_dir], env=env
)
_replace_in_tree(
install_dir, work_root.encode(), WORK_ROOT_TOKEN, BUILD_MTIME
)
_set_tree_mtime(install_dir, BUILD_MTIME)
entries = sorted(os.listdir(install_dir))
_tar_create(out_tar, install_dir, entries)
def _cmd_run_test(args):
"""Compiles, links, and executes CMake test targets for a single test rule."""
exec_root = os.getcwd()
out_log = os.path.abspath(args.out_log)
test_libs_tar = os.path.join(exec_root, args.test_libs_tar)
with open(args.test_spec) as sf:
exes = json.load(sf).get("exes", [])
if not exes:
with open(out_log, "w") as lf:
lf.write(
f"SKIPPED {args.test_label} (not part of any CMake test"
" executable)\n"
)
return
with tempfile.TemporaryDirectory(prefix="cmake_test_") as work_root:
subprocess.check_call(["tar", "-xf", test_libs_tar, "-C", work_root])
src_root = os.path.join(work_root, "src")
build_dir = os.path.join(work_root, "build")
deps_dir = os.path.join(work_root, "deps")
os.makedirs(src_root, exist_ok=True)
os.makedirs(build_dir, exist_ok=True)
_replace_in_tree(
build_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME
)
_replace_in_tree(deps_dir, WORK_ROOT_TOKEN, work_root.encode(), BUILD_MTIME)
_copy_mapped_files(exec_root, src_root, args.hdr_map, mtime=STUB_MTIME)
_copy_mapped_files(exec_root, src_root, args.src_map, mtime=STUB_MTIME)
extra_env = {}
tz_exec = os.path.join(
exec_root, "absl/time/internal/cctz/testdata/zoneinfo"
)
tz_src = os.path.join(src_root, "absl/time/internal/cctz/testdata/zoneinfo")
if os.path.isdir(tz_exec):
extra_env["TZDIR"] = tz_exec
elif os.path.isdir(tz_src):
extra_env["TZDIR"] = tz_src
env = _make_env(args, work_root=work_root, extra_env=extra_env)
all_obj_cmds = []
seen_objs = set()
for exe in exes:
for obj_out, obj_cmd in exe.get("obj_cmds", []):
if obj_out not in seen_objs:
seen_objs.add(obj_out)
all_obj_cmds.append((obj_out, obj_cmd))
sorted_targets = [exe["cmake_name"] for exe in exes]
ctest_lines = []
for exe in exes:
for line in exe.get("ctest_lines", []):
ctest_lines.append(
line.replace(WORK_ROOT_TOKEN.decode(), work_root) + "\n"
)
with open(os.path.join(build_dir, "CTestTestfile.cmake"), "w") as cf:
cf.writelines(ctest_lines)
ctest_regex = "^(" + "|".join(sorted_targets) + ")(_shard_[0-9]+)?$"
with open(out_log, "w") as lf:
lf.write(f"=== {args.test_label} ({' '.join(sorted_targets)}) ===\n")
lf.flush()
def _run_step(cmd_str, out_rel=None):
if out_rel:
os.makedirs(
os.path.dirname(os.path.join(build_dir, out_rel)), exist_ok=True
)
p = subprocess.run(
cmd_str,
shell=True,
cwd=build_dir,
stdout=lf,
stderr=subprocess.STDOUT,
env=env,
check=False,
)
if p.returncode != 0:
with open(out_log) as rf:
sys.stderr.write(rf.read())
sys.exit(p.returncode)
for obj_out, obj_cmd in all_obj_cmds:
_run_step(obj_cmd.replace(WORK_ROOT_TOKEN.decode(), work_root), obj_out)
for exe in exes:
_run_step(
exe["exe_cmd"].replace(WORK_ROOT_TOKEN.decode(), work_root),
exe["exe_out"],
)
p = subprocess.run(
[
"ctest",
"--test-dir",
build_dir,
"-R",
ctest_regex,
"--output-on-failure",
"--repeat",
"until-pass:3",
],
stdout=lf,
stderr=subprocess.STDOUT,
env=env,
check=False,
)
if p.returncode != 0:
with open(out_log) as rf:
sys.stderr.write(rf.read())
sys.exit(p.returncode)
def _cmd_install_test(args):
"""Runs the CMake installation integration test script in a temp directory."""
exec_root = os.getcwd()
out_log = os.path.abspath(args.out_log)
with tempfile.TemporaryDirectory(prefix="cmake_install_test_") as work_dir:
_copy_mapped_files(exec_root, work_dir, args.src_map, mtime=None)
options_h = os.path.join(work_dir, "absl", "base", "options.h")
if os.path.isfile(options_h):
with open(options_h) as rf:
content = rf.read()
content = content.replace(
"#define ABSL_OPTION_USE_INLINE_NAMESPACE 1",
"#define ABSL_OPTION_USE_INLINE_NAMESPACE 0",
).replace(
"#define ABSL_OPTION_INLINE_NAMESPACE_NAME ns",
"#define ABSL_OPTION_INLINE_NAMESPACE_NAME head",
)
with open(options_h, "w") as wf:
wf.write(content)
for root, _, files in os.walk(work_dir):
for f in files:
if f.endswith((".sh", ".py")):
os.chmod(os.path.join(root, f), 0o755)
home_dir = os.path.join(work_dir, "_home")
os.makedirs(home_dir, exist_ok=True)
extra_env = {
"LINK_TYPE": args.link_type,
"ABSL_INSTALL_TEST_BUILD_DIR": os.path.join(work_dir, "_buildfs"),
"ABSL_INSTALL_TEST_PREFIX": os.path.join(work_dir, "_install"),
"ABSL_INSTALL_TEST_GTEST_BUILD_DIR": os.path.join(work_dir, "_gtest"),
"ABSL_INSTALL_TEST_BUILD_TESTING": "OFF",
"ABSL_GOOGLETEST_VERSION": "none",
"ABSL_GOOGLETEST_DOWNLOAD_URL": "",
"TZDIR": os.path.join(
work_dir, "absl/time/internal/cctz/testdata/zoneinfo"
),
"HOME": home_dir,
}
env = _make_env(args, work_root=work_dir, extra_env=extra_env)
script_path = os.path.join(work_dir, args.script)
with open(out_log, "w") as lf:
p = subprocess.run(
[script_path],
cwd=work_dir,
stdout=lf,
stderr=subprocess.STDOUT,
env=env,
check=False,
)
if p.returncode != 0:
with open(out_log) as rf:
sys.stderr.write(rf.read())
sys.exit(p.returncode)
def _cmd_verify_logs(args):
"""Verifies that all expected CMake test action log files exist."""
for log_path in args.log:
if not os.path.exists(log_path):
sys.stderr.write(f"Missing CMake test log: {log_path}\n")
sys.exit(1)
print(f"Verified {len(args.log)} CMake ({args.mode}) test action logs.")
def _add_common_args(p):
"""Registers common toolchain and compiler flag CLI arguments on `p`."""
p.add_argument("--cc", required=True)
p.add_argument("--cxx", required=True)
p.add_argument("--ar", default="ar")
p.add_argument("--cxx-std", default="17")
p.add_argument("--cflag", action="append", default=[])
p.add_argument("--cxxflag", action="append", default=[])
p.add_argument("--ldflag", action="append", default=[])
def main():
"""Parses CLI arguments and dispatches to the requested subcommand."""
if len(sys.argv) == 1:
candidate = sys.argv[0] + ".args"
if os.path.exists(candidate):
sys.argv.append("@" + candidate)
elif os.environ.get("TEST_TARGET"):
target_rel = os.environ["TEST_TARGET"].lstrip("/").replace(":", "/")
if os.path.exists(target_rel + ".args"):
sys.argv.append("@" + target_rel + ".args")
parser = argparse.ArgumentParser(fromfile_prefix_chars="@")
subparsers = parser.add_subparsers(dest="subcommand", required=True)
p_gtest = subparsers.add_parser("build_gtest")
_add_common_args(p_gtest)
p_gtest.add_argument("--build-type", required=True)
p_gtest.add_argument("--shared", required=True)
p_gtest.add_argument("--src-map", action="append", default=[])
p_gtest.add_argument("--out-tar", required=True)
p_gtest.set_defaults(func=_cmd_build_gtest)
p_cfg = subparsers.add_parser("configure")
_add_common_args(p_cfg)
p_cfg.add_argument("--build-type", required=True)
p_cfg.add_argument("--shared", required=True)
p_cfg.add_argument("--dep-tar", action="append", default=[])
p_cfg.add_argument("--src-map", action="append", default=[])
p_cfg.add_argument("--cmake-arg", action="append", default=[])
p_cfg.add_argument("--objs-spec-out", required=True)
p_cfg.add_argument("--libs-spec-out", required=True)
p_cfg.add_argument("--tests-spec-out", required=True)
p_cfg.add_argument("--install-build-out", required=True)
p_cfg.set_defaults(func=_cmd_configure)
p_slice_obj = subparsers.add_parser("slice_obj_spec")
p_slice_obj.add_argument("--objs-spec", required=True)
p_slice_obj.add_argument("--cc-rel", action="append", default=[])
p_slice_obj.add_argument("--out", required=True)
p_slice_obj.set_defaults(func=_cmd_slice_obj_spec)
p_objs = subparsers.add_parser("compile_objs")
_add_common_args(p_objs)
p_objs.add_argument("--obj-spec", required=True)
p_objs.add_argument("--dep-tar", action="append", default=[])
p_objs.add_argument("--src-map", action="append", default=[])
p_objs.add_argument("--out-tar", required=True)
p_objs.set_defaults(func=_cmd_compile_objs)
p_libs = subparsers.add_parser("link_libs")
_add_common_args(p_libs)
p_libs.add_argument("--libs-spec", required=True)
p_libs.add_argument("--dep-tar", action="append", default=[])
p_libs.add_argument("--obj-tar", action="append", default=[])
p_libs.add_argument("--out-tar", required=True)
p_libs.set_defaults(func=_cmd_link_libs)
p_slice_test_spec = subparsers.add_parser("slice_test_spec")
p_slice_test_spec.add_argument("--tests-spec", required=True)
p_slice_test_spec.add_argument("--cc-rel", action="append", default=[])
p_slice_test_spec.add_argument("--spec-out", required=True)
p_slice_test_spec.add_argument("--req-libs-out", required=True)
p_slice_test_spec.set_defaults(func=_cmd_slice_test_spec)
p_slice_test_libs = subparsers.add_parser("slice_test_libs")
p_slice_test_libs.add_argument("--req-libs-spec", required=True)
p_slice_test_libs.add_argument("--libs-tar", required=True)
p_slice_test_libs.add_argument("--out-tar", required=True)
p_slice_test_libs.set_defaults(func=_cmd_slice_test_libs)
p_pkg = subparsers.add_parser("package")
_add_common_args(p_pkg)
p_pkg.add_argument("--install-build-tar", required=True)
p_pkg.add_argument("--libs-tar", required=True)
p_pkg.add_argument("--hdr-map", action="append", default=[])
p_pkg.add_argument("--out-tar", required=True)
p_pkg.set_defaults(func=_cmd_package)
p_test = subparsers.add_parser("run_test")
_add_common_args(p_test)
p_test.add_argument("--test-spec", required=True)
p_test.add_argument("--test-libs-tar", required=True)
p_test.add_argument("--hdr-map", action="append", default=[])
p_test.add_argument("--src-map", action="append", default=[])
p_test.add_argument("--test-label", required=True)
p_test.add_argument("--out-log", required=True)
p_test.set_defaults(func=_cmd_run_test)
p_inst = subparsers.add_parser("install_test")
_add_common_args(p_inst)
p_inst.add_argument("--link-type", required=True)
p_inst.add_argument("--script", required=True)
p_inst.add_argument("--src-map", action="append", default=[])
p_inst.add_argument("--out-log", required=True)
p_inst.set_defaults(func=_cmd_install_test)
p_verify = subparsers.add_parser("verify_logs")
p_verify.add_argument("--mode", required=True)
p_verify.add_argument("--log", action="append", default=[])
p_verify.set_defaults(func=_cmd_verify_logs)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()