blob: 564afb9711fd59c3a1762f15dbbdee9dd61e7073 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Generate a Bazel repository for a given Fuchsia IDK.
This rewrite the metadata files to use Bazel target labels for
any file that is a Ninja artifact, instead of a source file,
e.g. "arch/x64/lib/libfoo.so" -> "@fuchsia_idk//arch/x64:lib/libfoo.so"
For now, these targets only map to filegroup() targets that wrap
a symlink to the actual Ninja artifact. Later, these will point to
actual Bazel actions to build the corresponding file on demand
directly in the Bazel graph.
"""
import argparse
import json
import os
import sys
import typing as T
from pathlib import Path
# Location of IDK top-level manifest listing all IDK atoms.
_IDK_MANIFEST_RELPATH = "meta/manifest.json"
# Glean the list of libc's generated headers from its checked-in JSON data.
_SYSROOT_GENERATED_HEADERS = sorted(
[
header["name"]
for header in sum(
(
json.load(
(
Path(__file__).parents[3]
/ "sdk"
/ "lib"
/ "c"
/ "include"
/ file
).open()
)
for file in ["generated.json", "llvm-libc-generated.json"]
),
[],
)
]
)
def _write_file(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink():
path.unlink()
path.write_text(content)
def _create_symlink(dst_path: Path, target_path: Path) -> None:
"""Create symlink at |dst_path| pointing to |target_path|."""
dst_dir = dst_path.parent
dst_dir.mkdir(parents=True, exist_ok=True)
if not target_path.is_absolute():
target_path = target_path.resolve()
target_path = Path(os.path.relpath(target_path, dst_dir))
if dst_path.is_symlink() or dst_path.exists():
dst_path.unlink()
dst_path.symlink_to(target_path)
def _to_starlark_string_list(items: T.List[str], indent: int = 4) -> str:
if len(items) == 0:
return "[]"
if len(items) == 1:
return f'["{items.pop()}"]'
result = "[\n"
for item in items:
result += " " * indent + f' "{item}",\n'
result += " " * indent + "]"
return result
def _to_starlark_string_dict(mapping: T.Dict[str, str], indent: int = 4) -> str:
if not mapping:
return "{}"
if len(mapping) == 1:
key, value = mapping.popitem()
return f'{{ "{key}": "{value}" }}'
items = sorted(mapping.items())
result = "{\n"
for key, value in items:
result += " " * indent + f' "{key}": "{value}",\n'
result += " " * indent + "}"
return result
def split_path_to_package_name(path: str) -> tuple[str, str]:
"""Convert IDK-relative path to Bazel (package path, target/file name) pair."""
# Use a single .build-id/BUILD.bazel file for all .build-id files.
if path.startswith(".build-id/"):
return (".build-id", path[len(".build-id/") :])
# Use a single package per arch/{cpu}/ directory as well.
# Special case arch/{cpu}/sysroot/ too.
if path.startswith("arch/"):
path_components = path.split("/")
assert len(path_components) >= 3, f"Unexpected arch-related path {path}"
if path_components[2] == "sysroot":
package_name, rest = (
f"arch/{path_components[1]}/sysroot",
path_components[3:],
)
else:
package_name, rest = (
f"arch/{path_components[1]}",
path_components[2:],
)
return (package_name, "/".join(rest))
# Use a single packages/<name>/BUILD.bazel for prebuilt packages.
# Note that this includes packages/blobs/BUILD.bazel.
if path.startswith("packages/"):
path_components = path.split("/")
assert (
len(path_components) > 2
), f"Unexpected package-related path: {path}"
package_name, rest = path_components[1], path_components[2:]
return (f"packages/{package_name}", "/".join(rest))
# Otherwise, use <dirname>, <basename>, with <dirname> set to the
# empty string instead of "." for the current directory.
p = Path(path)
package = str(p.parent)
if package == ".":
package = ""
return (package, p.name)
class OutputPackageInfo(object):
"""Information about a given Bazel package in the output IDK directory."""
def __init__(self) -> None:
# The list of files exported by this package.
self._exports: T.Set[str] = set()
# The list of filesgroups in this package.
self._filegroups: T.Dict[str, T.List[str]] = {}
# The list of aliases in this package.
self._aliases: T.Dict[str, str] = {}
self._target_names: T.Set[str] = set()
def add_export(self, name: str) -> None:
assert (
name not in self._target_names
), f"Cannot export known target name: {name}"
self._exports.add(name)
def add_filegroup(self, name: str, srcs: T.Sequence[str]) -> None:
assert name not in self._target_names, f"Duplicate target name: {name}"
self._target_names.add(name)
self._filegroups[name] = list(srcs)
def add_alias(self, name: str, actual: str) -> None:
assert name not in self._target_names, f"Duplicate target name: {name}"
self._target_names.add(name)
self._aliases[name] = actual
def generate_build_bazel(self) -> str:
result = "# AUTO-GENERATED - DO NOT EDIT !\n\n"
result += 'package(default_visibility = ["//visibility:public"])\n'
if self._exports:
result += """
# The following are direct symlinks that can point into the Ninja output directory or the Fuchsia source dir.
exports_files({exports})
""".format(
exports=_to_starlark_string_list(
sorted(self._exports), indent=0
)
)
for name, srcs in self._filegroups.items():
srcs_list = _to_starlark_string_list(sorted(srcs))
result += f"""
filegroup(
name = "{name}",
srcs = {srcs_list},
)
"""
for name, actual in self._aliases.items():
result += f"""
alias(
name = "{name}",
actual = "{actual}",
)
"""
return result
class OutputIdk(object):
"""Information about a given output IDK directory."""
def __init__(self, output_dir: Path):
self._output_dir = output_dir
self._packages: T.Dict[str, OutputPackageInfo] = {}
self._symlinks: T.Dict[str, Path] = {}
self._files: T.Dict[str, str] = {}
# A map from Bazel target labels to where to copy their
# artifact when generating a final IDK directory.
self._final_files: T.Dict[str, str] = {}
self._final_metas: T.Dict[str, str] = {}
def add_symlink(self, link_relpath: str, target_path: Path) -> None:
cur_path = self._symlinks.setdefault(link_relpath, target_path)
assert (
cur_path == target_path
), f"Duplicate symlinks at {link_relpath}: [{cur_path}] vs [{target_path}]"
assert (
link_relpath not in self._files
), f"Cannot add symlink {link_relpath} over existing file"
def add_file(
self, relpath: str, content: str, is_meta: bool = False
) -> None:
cur_content = self._files.setdefault(relpath, content)
assert (
cur_content == content
), f"Duplicate file content at {relpath}:\n<<<<<<\n{cur_content}\n=====\n{content}\n>>>>"
assert (
relpath not in self._symlinks
), f"Cannot add file {relpath} over existing symlink"
self.add_final_file(relpath, is_meta)
def add_json_file(
self, relpath: str, json_content: T.Any, is_meta: bool = False
) -> None:
self.add_file(
relpath,
json.dumps(json_content, indent=2, separators=(",", ": ")),
is_meta=is_meta,
)
def get_package_info_for(self, package_path: str) -> OutputPackageInfo:
result = self._packages.get(package_path)
if result is None:
result = self._packages.setdefault(
package_path, OutputPackageInfo()
)
return result
def add_final_file(self, dst_path: str, is_meta: bool = False) -> None:
dst_path = str(
dst_path
) # TODO(digit): Add assert, debug origin of Path value!
package, name = split_path_to_package_name(dst_path)
package_info = self.get_package_info_for(package)
package_info.add_export(name)
if is_meta:
self._final_metas[f"//%s:%s" % (package, name)] = dst_path
else:
self._final_files[f"//%s:%s" % (package, name)] = dst_path
def add_artifact_file(self, dst_path: str, target_label: str) -> None:
self._final_files[target_label] = dst_path
def write_all(self) -> None:
# Ensure there is an OutputPackageInfo for the top-level package.
self.get_package_info_for("")
# Write all package BUILD.bazel files.
for package_dir, package_info in self._packages.items():
package_path = self._output_dir / package_dir / "BUILD.bazel"
package_path.parent.mkdir(parents=True, exist_ok=True)
package_path.write_text(package_info.generate_build_bazel())
# Create symlinks.
for link_relpath, target_path in self._symlinks.items():
link_path = self._output_dir / link_relpath
_create_symlink(link_path, target_path)
# Write files.
for file_relpath, content in self._files.items():
file_path = self._output_dir / file_relpath
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
class PathRewriter(object):
"""Rewrite paths belonging to a metadata JSON file."""
def __init__(
self,
canonical_repo_name: str,
output_idk: OutputIdk,
input_dir: Path,
ninja_build_dir: Path,
exceptions: T.Sequence[str] = [],
):
"""Create new instance
Args:
repo_name: Repository name (e.g. "fuchsia_idk").
output_idk: An OutputIdk instance.
input_dir: Path to the input export IDK directory.
ninja_build_dir: Path to the Ninja build directory.
exceptions: optional list of file basenames that will
be preserved as symlinks, even if they are generated
Ninja artifacts.
"""
self._canonical_repo_name = canonical_repo_name
self._output_idk = output_idk
self._input_dir = input_dir
self._ninja_build_dir = ninja_build_dir
self._exceptions = exceptions
def clone_with_exceptions(
self, exceptions: T.Sequence[str]
) -> "PathRewriter":
"""Create new instance with a different list of exceptions."""
return PathRewriter(
self._canonical_repo_name,
self._output_idk,
self._input_dir,
self._ninja_build_dir,
exceptions,
)
def path(self, path: str, relative_from: None | Path = None) -> str:
"""Rewrite a single path, potentially creating a symlink for it."""
package, name = split_path_to_package_name(path)
if relative_from:
assert not path.startswith(
("/", "@")
), f"Expected relative file: {path}"
src_path = (relative_from / path).resolve()
else:
src_path = (self._input_dir / path).resolve()
# Create a symlink, and ensure the file is exported
# by its Bazel package.
self._output_idk.add_symlink(path, src_path)
pkg_info = self._output_idk.get_package_info_for(package)
pkg_info.add_export(name)
if not src_path.is_relative_to(self._ninja_build_dir):
# This is a source file, return the path unmodified.
self._output_idk.add_final_file(path)
return path
elif os.path.basename(path) in self._exceptions:
# This file must be kept as a direct symlink even
# though it is generated by Ninja. This is currently
# required for sysroot files because the C++ toolchain
# configuration cannot currently support a generated
# sysroot directory.
self._output_idk.add_final_file(path)
return path
else:
# This is a Ninja generated file, return a label to
# its Bazel package.
label = f"@@{self._canonical_repo_name}//{package}:{name}"
self._output_idk.add_artifact_file(path, label)
return label
def path_list(
self, paths: T.List[str], relative_from: None | Path = None
) -> T.List[str]:
"""Rewrite all paths in a list, return new list."""
return [self.path(p, relative_from) for p in paths]
def property_inplace(self, obj: T.Dict[str, str], prop: str) -> None:
"""Rewrite obj[prop] as path in-place if possible."""
value: str | None = obj.get(prop, None)
if value is not None:
obj[prop] = self.path(value)
def properties_inplace(
self, obj: T.Dict[str, str], props: T.Sequence[str]
) -> None:
"""Applies property_inplace() for a sequence of property names."""
for prop in props:
self.property_inplace(obj, prop)
def path_list_inplace(self, paths: T.List[str]) -> None:
"""Rewrite all paths in a list, modify in-place."""
# Subtle: using `path_list[:] = new_value` changes the content
# of path_list in-place, instead of assigning a new value to
# the local variable by that name.
paths[:] = self.path_list(paths)
def rewrite_metadata(self, meta: T.Any, meta_dir: str) -> None:
"""Rewrite metadata JSON value in-place.
Args:
meta: The meta.json file as a JSON dict.
meta_dir: The directory where the file lives in.
"""
atom_type = meta.get("type")
# version_history.json places the type under data.type
if atom_type is None and "data" in meta:
atom_type = meta["data"].get("type")
if atom_type == "bind_library":
self.path_list_inplace(meta["sources"])
return
if atom_type == "cc_prebuilt_library":
self.path_list_inplace(meta["headers"])
binaries = meta.get("binaries", {})
for arch, binary_group in binaries.items():
self.properties_inplace(binary_group, ("debug", "dist", "link"))
for variant in meta.get("variants", []):
values = variant["values"]
self.properties_inplace(
values, ("debug", "dist_lib", "link_lib")
)
# Handle the symlink for the ifs file, which is listed by
# name only, instead of an SDK-relative file path.
# E.g. 'foo.ifs' instead of 'pkg/foo/foo.ifs'
meta_ifs = meta.get("ifs")
if meta_ifs:
self.path(f"{meta_dir}/{meta_ifs}")
return
if atom_type == "cc_source_library":
self.path_list_inplace(meta["headers"])
self.path_list_inplace(meta["sources"])
return
if atom_type == "companion_host_tool":
if "files" in meta:
self.path_list_inplace(meta["files"])
target_files = meta.get("target_files", {})
for arch, filegroup in target_files.items():
self.path_list_inplace(filegroup)
return
if atom_type == "dart_library":
self.path_list_inplace(meta["sources"])
return
if atom_type in ("component_manifest", "config", "license"):
self.path_list_inplace(meta["data"])
return
if atom_type == "documentation":
self.path_list_inplace(meta["docs"])
return
if atom_type == "emu_manifest":
self.path_list_inplace(meta["disk_images"])
self.property_inplace(meta, "initial_ramdisk")
self.property_inplace(meta, "kernel")
return
if atom_type == "experimental_python_e2e_test":
self.path_list_inplace(meta["files"])
return
if atom_type == "ffx_tool":
toolfiles = meta.get("files", {})
self.property_inplace(toolfiles, "executable")
self.property_inplace(toolfiles, "executable_metadata")
for arch, toolfiles in meta["target_files"].items():
self.property_inplace(toolfiles, "executable")
self.property_inplace(toolfiles, "executable_metadata")
return
if atom_type == "fidl_library":
self.path_list_inplace(meta["sources"])
return
if atom_type == "host_tool":
self.path_list_inplace(meta.get("files", []))
for arch, filegroup in meta.get("target_files", {}).items():
self.path_list_inplace(filegroup)
return
if atom_type == "loadable_module":
for arch, filegroup in meta["binaries"].items():
self.path_list_inplace(filegroup)
self.path_list_inplace(meta["resources"])
return
if atom_type == "package":
for variant in meta["variants"]:
self.property_inplace(variant, "manifest_file")
self.path_list_inplace(variant["files"])
return
if atom_type == "sysroot":
# The following files are generated by Ninja.
# For now, keep them as direct symlinks until
# the Bazel C++ toolchain definition can support
# sysroot generated by Bazel actions.
sysroot_rewriter = self.clone_with_exceptions(
_SYSROOT_GENERATED_HEADERS
+ [
# Generated syscall cdecl source files.
# LINT.IfChange(sysroot_generated_cdecl_sources)
"cdecls.inc",
"cdecls-next.inc",
# LINT.ThenChange(//zircon/tools/zither/zither_library.gni)
# Prebuilt libraries.
"libc.so",
"libzircon.so",
"Scrt1.o",
]
)
for variant in meta.get("variants", []):
values = variant["values"]
sysroot_rewriter.path_list_inplace(values["debug_libs"])
sysroot_rewriter.path_list_inplace(values["dist_libs"])
sysroot_rewriter.path_list_inplace(values["headers"])
sysroot_rewriter.path_list_inplace(values["link_libs"])
for arch, version in meta.get("versions", {}).items():
sysroot_rewriter.path_list_inplace(version["debug_libs"])
sysroot_rewriter.path_list_inplace(version["dist_libs"])
sysroot_rewriter.path_list_inplace(version["headers"])
sysroot_rewriter.path_list_inplace(version["link_libs"])
for ifs in meta.get("ifs_files", []):
sysroot_rewriter.path(f"{meta_dir}/{ifs}")
return
if atom_type == "version_history":
# No paths to rewrite here.
return
if atom_type == "virtual_device":
# No paths to rewrite here.
return
raise Exception(f"Unknown atom type {atom_type} in {meta}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repository-name", required=True, help="IDK repository name."
)
parser.add_argument(
"--output-dir", type=Path, required=True, help="Output directory path."
)
parser.add_argument(
"--input-dir",
type=Path,
required=True,
help="Input IDK export directory path.",
)
parser.add_argument(
"--fuchsia-dir",
type=Path,
# Assume this script is under //build/bazel/scripts/
default=Path(__file__).parent.parent.parent.parent,
help="Specify Fuchsia source directory (auto-detected)",
)
parser.add_argument(
"--ninja-build-dir",
type=Path,
help="Specify Ninja output directory (auto-detected)",
)
args = parser.parse_args()
if not args.ninja_build_dir:
# args.fuchsia_dir points to the Bazel workspace, which contains symlinks
# to the real files in the actual top-level source directory. Resolve
# one of them to find the real location.
#
# out/default/gen/build/bazel/workspace/README.md -->
# ../../../../../../README.md
#
args.fuchsia_dir = (args.fuchsia_dir / "README.md").resolve().parent
if not (args.fuchsia_dir / ".jiri_manifest").exists():
parser.error(f"Invalid Fuchsia directory path: {args.fuchsia_dir}")
fx_build_dir = args.fuchsia_dir / ".fx-build-dir"
if not fx_build_dir.exists():
parser.error(
f"Cannot detect build directory, use 'fx set' in: {args.fuchsia_dir}"
)
build_subdir = fx_build_dir.read_text().strip()
if not build_subdir:
parser.error(
f"Cannot detect build directory, use --ninja-output-dir=DIR"
)
args.ninja_build_dir = args.fuchsia_dir / build_subdir
if not args.ninja_build_dir.exists():
parser.error(
f"Ninja build directory does not exist: {args.ninja_build_dir}"
)
GenerateIdkRepository(
args.repository_name,
args.output_dir,
args.input_dir,
args.ninja_build_dir,
)
return 0
def GenerateIdkRepository(
name: str, output_dir: Path, input_dir: Path, ninja_build_dir: Path
) -> None:
"""Generate a Bazel repository from the specified Fuchsia IDK directory.
Args:
name: Repository name.
output_dir: Path for the IDK repository directory.
input_dir: Path to the IDK directory from which to create the repository.
ninja_build_dir: Path to the Ninja build directory.
"""
input_dir = input_dir.resolve()
ninja_build_dir = ninja_build_dir.resolve()
output_idk = OutputIdk(output_dir)
# Symlink then source meta/manifest.json
input_manifest_path = input_dir / _IDK_MANIFEST_RELPATH
input_manifest = json.load(input_manifest_path.open())
output_idk.add_symlink(_IDK_MANIFEST_RELPATH, input_manifest_path)
rewriter = PathRewriter(name, output_idk, input_dir, ninja_build_dir)
# Handle each part separately.
for part in input_manifest["parts"]:
part_meta_path = part["meta"]
src_meta_path = input_dir / part_meta_path
try:
meta = json.load(src_meta_path.open())
except:
# In case of malformed input JSON file, print its path
# to ease debugging this script.
print(
f"INVALID METADATA FILE PATH {src_meta_path}", file=sys.stderr
)
raise
meta_dir = os.path.dirname(part_meta_path)
rewriter.rewrite_metadata(meta, meta_dir)
output_idk.add_json_file(part_meta_path, meta, is_meta=True)
output_idk.write_all()
if __name__ == "__main__":
sys.exit(main())