blob: 4c477257537531a02ddf62750eb3bcfd313712c7 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2020 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""This script assists with converting from Bazel BUILD files to CMakeLists.txt.
Bazel BUILD files should, where possible, be written to use simple features
that can be directly evaluated and avoid more advanced features like
variables, list comprehensions, etc.
Generated CMake files will be similar in structure to their source BUILD
files by using the functions in build_tools/cmake/ that imitate corresponding
Bazel rules (e.g. cc_library -> iree_cc_library.cmake).
Common usage:
Run across all default paths in the project (in .bazel_to_cmake.cfg.py):
$ python build_tools/bazel_to_cmake/bazel_to_cmake.py
Run on an individual file:
$ python build_tools/bazel_to_cmake/bazel_to_cmake.py runtime/src/iree/base/BUILD.bazel
Run on multiple files (e.g. as part of a pre-commit hook):
$ python build_tools/bazel_to_cmake/bazel_to_cmake.py runtime/src/iree/base/BUILD.bazel runtime/src/iree/vm/BUILD.bazel
Run on a single directory:
$ python build_tools/bazel_to_cmake/bazel_to_cmake.py --dir runtime/src/iree/base/
Run on all files under a root directory:
$ python build_tools/bazel_to_cmake/bazel_to_cmake.py --root-dir runtime/src/iree/
Configuration
-------------
When invoked, bazel_to_cmake will traverse up from the current directory until
it finds a ".bazel_to_cmake.cfg.py" file. This file both serves as a marker
for the repository root and provides repository specific configuration.
The file is evaluated as a module and can have the following customizations:
* DEFAULT_ROOT_DIRS: A list of root directory names that should be processed
(relative to the repository root) when invoked without a --repo_root or --dir.
* REPO_MAP: Mapping of canonical Bazel repo name (i.e. "@iree_core") to what it
is known as locally (most commonly the empty string). This is used in global
target rules to make sure that they work either in the defining or referencing
repository.
* CustomBuildFileFunctions: A class that extends
`bazel_to_cmake_converter.BuildFileFunctions` and injects globals for
processing the BUILD file. All symbols that do not start with "_" are
available.
* CustomTargetConverter: A class that extends
`bazel_to_cmake_targets.TargetConverter` and customizes target mapping.
Typically, this is used for purely local targets in leaf projects (as global
targets will be encoded in the main bazel_to_cmake_targets.py file).
"""
# pylint: disable=missing-docstring
import argparse
import importlib
import importlib.util
import os
import re
import sys
import textwrap
from enum import Enum
from pathlib import Path
import bazel_to_cmake_converter
repo_root = None
repo_cfg = None
EDIT_BLOCKING_PATTERN = re.compile(
r"bazel[\s_]*to[\s_]*cmake[\s_]*:?[\s_]*do[\s_]*not[\s_]*edit", flags=re.IGNORECASE
)
PRESERVE_ABOVE_TAG = "### BAZEL_TO_CMAKE_PRESERVES_ALL_CONTENT_ABOVE_THIS_LINE ###"
PRESERVE_BELOW_TAG = "### BAZEL_TO_CMAKE_PRESERVES_ALL_CONTENT_BELOW_THIS_LINE ###"
REPO_CFG_FILE = ".bazel_to_cmake.cfg.py"
REPO_CFG_MODULE_NAME = "bazel_to_cmake_repo_config"
class Status(Enum):
UPDATED = 1
NOOP = 2
FAILED = 3
SKIPPED = 4
NO_BUILD_FILE = 5
def parse_arguments():
parser = argparse.ArgumentParser(description="Bazel to CMake conversion helper.")
parser.add_argument(
"--preview",
help="Prints results instead of writing files",
action="store_true",
default=False,
)
parser.add_argument(
"--allow_partial_conversion",
help="Generates partial files, ignoring errors during conversion.",
action="store_true",
default=False,
)
parser.add_argument(
"--verbosity",
"-v",
type=int,
default=0,
help="Specify verbosity level where higher verbosity emits more logging."
" 0 (default): Only output errors and summary statistics."
" 1: Also output the name of each directory as it's being processed and"
" whether the directory is skipped."
" 2: Also output when conversion was successful.",
)
# Specify only one of these (defaults to --root_dir=<main source dirs>).
group = parser.add_mutually_exclusive_group()
group.add_argument(
"files", nargs="*", help="Converts (or generates) the given files", default=[]
)
group.add_argument(
"--dir", help="Converts the BUILD file in the given directory", default=None
)
default_root_dirs = (
repo_cfg.DEFAULT_ROOT_DIRS if hasattr(repo_cfg, "DEFAULT_ROOT_DIRS") else []
)
group.add_argument(
"--root_dir",
nargs="+",
help="Converts all BUILD files under a root directory",
default=default_root_dirs,
)
args = parser.parse_args()
# 'files' and '--dir' take precedence over '--root_dir'.
# They are mutually exclusive, but the default value is still set.
if args.files or args.dir:
args.root_dir = None
return args
def setup_environment():
"""Sets up some environment globals."""
global repo_root
global repo_cfg
# Scan up the directory tree for a repo config file.
check_dir = os.getcwd()
while not os.path.exists(os.path.join(check_dir, REPO_CFG_FILE)):
new_check_dir = os.path.dirname(check_dir)
if not new_check_dir or new_check_dir == check_dir:
print(
f"ERROR: Could not find {REPO_CFG_FILE} in a parent directory "
f"of {os.getcwd()}"
)
sys.exit(1)
check_dir = new_check_dir
repo_root = check_dir
log(f"Using repo root {repo_root}")
# Dynamically load the config file as a module.
orig_dont_write_bytecode = sys.dont_write_bytecode
sys.dont_write_bytecode = True # Don't generate __pycache__ dir
repo_cfg_path = os.path.join(repo_root, REPO_CFG_FILE)
spec = importlib.util.spec_from_file_location(REPO_CFG_MODULE_NAME, repo_cfg_path)
if spec and spec.loader:
repo_cfg = importlib.util.module_from_spec(spec)
sys.modules[REPO_CFG_MODULE_NAME] = repo_cfg
spec.loader.exec_module(repo_cfg)
sys.dont_write_bytecode = orig_dont_write_bytecode
else:
print(f"INTERNAL ERROR: Could not evaluate {repo_cfg_path} as module")
sys.exit(1)
def repo_relpath(path):
return os.path.relpath(path, repo_root).replace("\\", "/")
def log(string, *args, indent=0, **kwargs):
print(
textwrap.indent(string, prefix=(indent * " ")), *args, **kwargs, file=sys.stderr
)
def convert_directories(directories, write_files, allow_partial_conversion, verbosity):
failure_dirs = []
skip_count = 0
success_count = 0
noop_count = 0
for directory in directories:
status = convert_directory(
directory,
write_files=write_files,
allow_partial_conversion=allow_partial_conversion,
verbosity=verbosity,
)
if status == Status.FAILED:
failure_dirs.append(repo_relpath(directory))
elif status == Status.SKIPPED:
skip_count += 1
elif status == Status.UPDATED:
success_count += 1
elif status == Status.NOOP:
noop_count += 1
log(
f"{success_count} CMakeLists.txt files were updated, {skip_count} were"
f" skipped, and {noop_count} required no change."
)
if failure_dirs:
log(
f"ERROR: Encountered unexpected errors converting {len(failure_dirs)}"
" directories:"
)
log("\n".join(failure_dirs), indent=2)
sys.exit(1)
def convert_directory(directory_path, write_files, allow_partial_conversion, verbosity):
if not os.path.isdir(directory_path):
raise FileNotFoundError(f"Cannot find directory '{directory_path}'")
rel_dir_path = repo_relpath(directory_path)
if verbosity >= 1:
log(f"Processing {rel_dir_path}")
# Scan for a BUILD file.
build_file_found = False
build_file_basenames = ["BUILD", "BUILD.bazel"]
for build_file_basename in build_file_basenames:
build_file_path = os.path.join(directory_path, build_file_basename)
rel_build_file_path = repo_relpath(build_file_path)
if os.path.isfile(build_file_path):
build_file_found = True
break
cmakelists_file_path = os.path.join(directory_path, "CMakeLists.txt")
rel_cmakelists_file_path = repo_relpath(cmakelists_file_path)
if not build_file_found:
return Status.NO_BUILD_FILE
autogeneration_tag = f"Autogenerated by {repo_relpath(os.path.abspath(__file__))}"
header = "\n".join(
["#" * 80]
+ [
l.ljust(79) + "#"
for l in [
f"# {autogeneration_tag} from",
f"# {rel_build_file_path}",
"#",
"# Use iree_cmake_extra_content from iree/build_defs.oss.bzl to add arbitrary",
"# CMake-only content.",
"#",
f"# To disable autogeneration for this file entirely, delete this header.",
]
]
+ ["#" * 80]
)
old_lines = []
possible_preserved_header_lines = []
preserved_footer_lines = ["\n" + PRESERVE_BELOW_TAG + "\n"]
# Read CMakeLists.txt and check if it has the auto-generated header.
found_preserve_below_tag = False
found_preserve_above_tag = False
if os.path.isfile(cmakelists_file_path):
found_autogeneration_tag = False
with open(cmakelists_file_path) as f:
old_lines = f.readlines()
for line in old_lines:
if not found_preserve_above_tag:
possible_preserved_header_lines.append(line)
if not found_autogeneration_tag and autogeneration_tag in line:
found_autogeneration_tag = True
if not found_preserve_below_tag and PRESERVE_BELOW_TAG in line:
found_preserve_below_tag = True
elif not found_preserve_above_tag and PRESERVE_ABOVE_TAG in line:
found_preserve_above_tag = True
elif found_preserve_below_tag:
preserved_footer_lines.append(line)
if not found_autogeneration_tag:
if verbosity >= 1:
log(f"Skipped. Did not find autogeneration line.", indent=2)
return Status.SKIPPED
preserved_header = (
"".join(possible_preserved_header_lines) if found_preserve_above_tag else ""
)
preserved_footer = "".join(preserved_footer_lines)
# Read the Bazel BUILD file and interpret it.
with open(build_file_path, "rt") as build_file:
build_file_contents = build_file.read()
if "bazel-to-cmake: skip" in build_file_contents:
return Status.SKIPPED
build_file_code = compile(build_file_contents, build_file_path, "exec")
try:
converted_build_file = bazel_to_cmake_converter.convert_build_file(
build_file_code,
repo_cfg=repo_cfg,
build_dir=directory_path,
allow_partial_conversion=allow_partial_conversion,
)
except (NameError, NotImplementedError) as e:
log(
f"ERROR generating {rel_dir_path}.\n"
f"Missing a rule handler in bazel_to_cmake_converter.py?\n"
f"Reason: `{type(e).__name__}: {e}`",
indent=2,
)
return Status.FAILED
except KeyError as e:
log(
f"ERROR generating {rel_dir_path}.\n"
f"Missing a conversion in bazel_to_cmake_targets.py?\n"
f"Reason: `{type(e).__name__}: {e}`",
indent=2,
)
return Status.FAILED
converted_content = (
preserved_header + header + converted_build_file + preserved_footer
)
if write_files:
with open(cmakelists_file_path, "wt") as cmakelists_file:
cmakelists_file.write(converted_content)
else:
print(converted_content, end="")
if converted_content == "".join(old_lines):
if verbosity >= 2:
log(f"{rel_cmakelists_file_path} required no update", indent=2)
return Status.NOOP
if verbosity >= 2:
log(
f"Successfly generated {rel_cmakelists_file_path}"
f" from {rel_build_file_path}",
indent=2,
)
return Status.UPDATED
def main(args):
"""Runs Bazel to CMake conversion."""
global repo_root
write_files = not args.preview
if args.files:
convert_directories(
[Path(file).parent for file in args.files],
write_files=write_files,
allow_partial_conversion=args.allow_partial_conversion,
verbosity=args.verbosity,
)
elif args.root_dir:
for root_dir in args.root_dir:
root_directory_path = os.path.join(repo_root, root_dir)
log(f"Converting directory tree rooted at: {root_directory_path}")
convert_directories(
(root for root, _, _ in os.walk(root_directory_path)),
write_files=write_files,
allow_partial_conversion=args.allow_partial_conversion,
verbosity=args.verbosity,
)
elif args.dir:
convert_directories(
[os.path.join(repo_root, args.dir)],
write_files=write_files,
allow_partial_conversion=args.allow_partial_conversion,
verbosity=args.verbosity,
)
else:
log(
f"ERROR: None of (positional) 'file', '--root-dir', or '--dir' "
f"arguments or DEFAULT_ROOT_DIRS in .bazel_to_cmake.cfg.py: No "
f"conversion will be done"
)
sys.exit(1)
if __name__ == "__main__":
setup_environment()
main(parse_arguments())