| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2020 The Android Open Source Project |
| # |
| # 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 |
| # |
| # http://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. |
| """Add or update tests to TEST_MAPPING. |
| |
| This script uses Bazel to find reverse dependencies on a crate and generates a |
| TEST_MAPPING file. It accepts the absolute path to a crate as argument. If no |
| argument is provided, it assumes the crate is the current directory. |
| |
| Usage: |
| $ . build/envsetup.sh |
| $ lunch aosp_arm64-eng |
| $ update_crate_tests.py $ANDROID_BUILD_TOP/external/rust/crates/libc |
| |
| This script is automatically called by external_updater. |
| |
| A test_mapping_config.json file can be defined in the project directory to |
| configure the generated TEST_MAPPING file, for example: |
| |
| { |
| // Run tests in postsubmit instead of presubmit. |
| "postsubmit_tests":["foo"] |
| } |
| |
| """ |
| |
| import argparse |
| import glob |
| import json |
| import os |
| import platform |
| import re |
| import subprocess |
| import sys |
| from datetime import datetime |
| from pathlib import Path |
| |
| # Some tests requires specific options. Consider fixing the upstream crate |
| # before updating this dictionary. |
| TEST_OPTIONS = { |
| "ring_test_tests_digest_tests": [{"test-timeout": "600000"}], |
| "ring_test_src_lib": [{"test-timeout": "100000"}], |
| } |
| |
| # Groups to add tests to. "presubmit" runs x86_64 device tests+host tests, and |
| # "presubmit-rust" runs arm64 device tests on physical devices. |
| TEST_GROUPS = [ |
| "presubmit", |
| "presubmit-rust", |
| "postsubmit", |
| ] |
| |
| # Excluded tests. These tests will be ignored by this script. |
| TEST_EXCLUDE = [ |
| "ash_test_src_lib", |
| "ash_test_tests_constant_size_arrays", |
| "ash_test_tests_display", |
| "shared_library_test_src_lib", |
| "vulkano_test_src_lib", |
| |
| # These are helper binaries for aidl_integration_test |
| # and aren't actually meant to run as individual tests. |
| "aidl_test_rust_client", |
| "aidl_test_rust_service", |
| "aidl_test_rust_service_async", |
| |
| # This is a helper binary for AuthFsHostTest and shouldn't |
| # be run directly. |
| "open_then_run", |
| |
| # TODO: Remove when b/198197213 is closed. |
| "diced_client_test", |
| |
| "CoverageRustSmokeTest", |
| "libtrusty-rs-tests", |
| "terminal-size_test_src_lib", |
| ] |
| |
| # Excluded modules. |
| EXCLUDE_PATHS = [ |
| "//external/adhd", |
| "//external/crosvm", |
| "//external/libchromeos-rs", |
| "//external/vm_tools" |
| ] |
| |
| LABEL_PAT = re.compile('^//(.*):.*$') |
| EXTERNAL_PAT = re.compile('^//external/rust/') |
| |
| |
| class UpdaterException(Exception): |
| """Exception generated by this script.""" |
| |
| |
| class Env(object): |
| """Env captures the execution environment. |
| |
| It ensures this script is executed within an AOSP repository. |
| |
| Attributes: |
| ANDROID_BUILD_TOP: A string representing the absolute path to the top |
| of the repository. |
| """ |
| def __init__(self): |
| try: |
| self.ANDROID_BUILD_TOP = os.environ['ANDROID_BUILD_TOP'] |
| except KeyError: |
| raise UpdaterException('$ANDROID_BUILD_TOP is not defined; you ' |
| 'must first source build/envsetup.sh and ' |
| 'select a target.') |
| |
| |
| class Bazel(object): |
| """Bazel wrapper. |
| |
| The wrapper is used to call bazel queryview and generate the list of |
| reverse dependencies. |
| |
| Attributes: |
| path: The path to the bazel executable. |
| """ |
| def __init__(self, env): |
| """Constructor. |
| |
| Note that the current directory is changed to ANDROID_BUILD_TOP. |
| |
| Args: |
| env: An instance of Env. |
| |
| Raises: |
| UpdaterException: an error occurred while calling soong_ui. |
| """ |
| if platform.system() != 'Linux': |
| raise UpdaterException('This script has only been tested on Linux.') |
| self.path = os.path.join(env.ANDROID_BUILD_TOP, "build", "bazel", "bin", "bazel") |
| soong_ui = os.path.join(env.ANDROID_BUILD_TOP, "build", "soong", "soong_ui.bash") |
| |
| # soong_ui requires to be at the root of the repository. |
| os.chdir(env.ANDROID_BUILD_TOP) |
| print("Generating Bazel files...") |
| cmd = [soong_ui, "--make-mode", "bp2build"] |
| try: |
| subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True) |
| except subprocess.CalledProcessError as e: |
| raise UpdaterException('Unable to generate bazel workspace: ' + e.output) |
| |
| print("Building Bazel Queryview. This can take a couple of minutes...") |
| cmd = [soong_ui, "--build-mode", "--all-modules", "--dir=.", "queryview"] |
| try: |
| subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True) |
| except subprocess.CalledProcessError as e: |
| raise UpdaterException('Unable to update TEST_MAPPING: ' + e.output) |
| |
| def query_modules(self, path): |
| """Returns all modules for a given path.""" |
| cmd = self.path + " query --config=queryview /" + path + ":all" |
| out = subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True).strip().split("\n") |
| modules = set() |
| for line in out: |
| # speed up by excluding unused modules. |
| if "windows_x86" in line: |
| continue |
| modules.add(line) |
| return modules |
| |
| def query_rdeps(self, module): |
| """Returns all reverse dependencies for a single module.""" |
| cmd = (self.path + " query --config=queryview \'rdeps(//..., " + |
| module + ")\' --output=label_kind") |
| out = (subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True) |
| .strip().split("\n")) |
| if '' in out: |
| out.remove('') |
| return out |
| |
| def exclude_module(self, module): |
| for path in EXCLUDE_PATHS: |
| if module.startswith(path): |
| return True |
| return False |
| |
| # Return all the TEST_MAPPING files within a given path. |
| def find_all_test_mapping_files(self, path): |
| result = [] |
| for root, dirs, files in os.walk(path): |
| if "TEST_MAPPING" in files: |
| result.append(os.path.join(root, "TEST_MAPPING")) |
| return result |
| |
| # For a given test, return the TEST_MAPPING file where the test is mapped. |
| # This limits the search to the directory specified in "path" along with its subdirs. |
| def test_to_test_mapping(self, env, path, test): |
| test_mapping_files = self.find_all_test_mapping_files(env.ANDROID_BUILD_TOP + path) |
| for file in test_mapping_files: |
| with open(file) as fd: |
| if "\""+ test + "\"" in fd.read(): |
| mapping_path = file.split("/TEST_MAPPING")[0].split("//")[1] |
| return mapping_path |
| |
| return None |
| |
| # Returns: |
| # rdep_test: for tests specified locally. |
| # rdep_dirs: for paths to TEST_MAPPING files for reverse dependencies. |
| # |
| # We import directories for non-local tests because including tests directly has proven to be |
| # fragile and burdensome. For example, whenever a project removes or renames a test, all the |
| # TEST_MAPPING files for its reverse dependencies must be updated or we get test breakages. |
| # That can be many tens of projects that must updated to prevent the reported breakage of tests |
| # that no longer exist. Similarly when a test is added, it won't be run when the reverse |
| # dependencies change unless/until update_crate_tests.py is run for its depenencies. |
| # Importing TEST_MAPPING files instead of tests solves both of these problems. When tests are |
| # removed, renamed, or added, only files local to the project need to be modified. |
| # The downside is that we potentially miss some tests. But this seems like a reasonable |
| # tradeoff. |
| def query_rdep_tests_dirs(self, env, modules, path, exclude_dir): |
| """Returns all reverse dependency tests for modules in this package.""" |
| rdep_tests = set() |
| rdep_dirs = set() |
| path_pat = re.compile("^/%s:.*$" % path) |
| for module in modules: |
| for rdep in self.query_rdeps(module): |
| rule_type, _, mod = rdep.split(" ") |
| if rule_type == "rust_test_" or rule_type == "rust_test": |
| if self.exclude_module(mod): |
| continue |
| path_match = path_pat.match(mod) |
| if path_match or not EXTERNAL_PAT.match(mod): |
| rdep_path = mod.split(":")[0] |
| rdep_test = mod.split(":")[1].split("--")[0] |
| mapping_path = self.test_to_test_mapping(env, rdep_path, rdep_test) |
| # Only include tests directly if they're local to the project. |
| if (mapping_path is not None) and exclude_dir.endswith(mapping_path): |
| rdep_tests.add(rdep_test) |
| # All other tests are included by path. |
| elif mapping_path is not None: |
| rdep_dirs.add(mapping_path) |
| else: |
| label_match = LABEL_PAT.match(mod) |
| if label_match: |
| rdep_dirs.add(label_match.group(1)) |
| return (rdep_tests, rdep_dirs) |
| |
| |
| class Package(object): |
| """A Bazel package. |
| |
| Attributes: |
| dir: The absolute path to this package. |
| dir_rel: The relative path to this package. |
| rdep_tests: The list of computed reverse dependencies. |
| rdep_dirs: The list of computed reverse dependency directories. |
| """ |
| def __init__(self, path, env, bazel): |
| """Constructor. |
| |
| Note that the current directory is changed to the package location when |
| called. |
| |
| Args: |
| path: Path to the package. |
| env: An instance of Env. |
| bazel: An instance of Bazel. |
| |
| Raises: |
| UpdaterException: the package does not appear to belong to the |
| current repository. |
| """ |
| self.dir = path |
| try: |
| self.dir_rel = self.dir.split(env.ANDROID_BUILD_TOP)[1] |
| except IndexError: |
| raise UpdaterException('The path ' + self.dir + ' is not under ' + |
| env.ANDROID_BUILD_TOP + '; You must be in the ' |
| 'directory of a crate or pass its absolute path ' |
| 'as the argument.') |
| |
| # Move to the package_directory. |
| os.chdir(self.dir) |
| modules = bazel.query_modules(self.dir_rel) |
| (self.rdep_tests, self.rdep_dirs) = bazel.query_rdep_tests_dirs(env, modules, |
| self.dir_rel, self.dir) |
| |
| def get_rdep_tests_dirs(self): |
| return (self.rdep_tests, self.rdep_dirs) |
| |
| |
| class TestMapping(object): |
| """A TEST_MAPPING file. |
| |
| Attributes: |
| package: The package associated with this TEST_MAPPING file. |
| """ |
| def __init__(self, env, bazel, path): |
| """Constructor. |
| |
| Args: |
| env: An instance of Env. |
| bazel: An instance of Bazel. |
| path: The absolute path to the package. |
| """ |
| self.package = Package(path, env, bazel) |
| |
| def create(self): |
| """Generates the TEST_MAPPING file.""" |
| (tests, dirs) = self.package.get_rdep_tests_dirs() |
| if not bool(tests) and not bool(dirs): |
| if os.path.isfile('TEST_MAPPING'): |
| os.remove('TEST_MAPPING') |
| return |
| test_mapping = self.tests_dirs_to_mapping(tests, dirs) |
| self.write_test_mapping(test_mapping) |
| |
| def tests_dirs_to_mapping(self, tests, dirs): |
| """Translate the test list into a dictionary.""" |
| test_mapping = {"imports": []} |
| config = None |
| if os.path.isfile(os.path.join(self.package.dir, "test_mapping_config.json")): |
| with open(os.path.join(self.package.dir, "test_mapping_config.json"), 'r') as fd: |
| config = json.load(fd) |
| |
| for test_group in TEST_GROUPS: |
| test_mapping[test_group] = [] |
| for test in tests: |
| if test in TEST_EXCLUDE: |
| continue |
| if config and 'postsubmit_tests' in config: |
| if test in config['postsubmit_tests'] and 'postsubmit' not in test_group: |
| continue |
| if test not in config['postsubmit_tests'] and 'postsubmit' in test_group: |
| continue |
| else: |
| if 'postsubmit' in test_group: |
| # If postsubmit_tests is not configured, do not place |
| # anything in postsubmit - presubmit groups are |
| # automatically included in postsubmit in CI. |
| continue |
| if test in TEST_OPTIONS: |
| test_mapping[test_group].append({"name": test, "options": TEST_OPTIONS[test]}) |
| else: |
| test_mapping[test_group].append({"name": test}) |
| test_mapping[test_group] = sorted(test_mapping[test_group], key=lambda t: t["name"]) |
| |
| for dir in dirs: |
| test_mapping["imports"].append({"path": dir}) |
| test_mapping["imports"] = sorted(test_mapping["imports"], key=lambda t: t["path"]) |
| test_mapping = {section: entry for (section, entry) in test_mapping.items() if entry} |
| return test_mapping |
| |
| def write_test_mapping(self, test_mapping): |
| """Writes the TEST_MAPPING file.""" |
| with open("TEST_MAPPING", "w") as json_file: |
| json_file.write("// Generated by update_crate_tests.py for tests that depend on this crate.\n") |
| json.dump(test_mapping, json_file, indent=2, separators=(',', ': '), sort_keys=True) |
| json_file.write("\n") |
| print("TEST_MAPPING successfully updated for %s!" % self.package.dir_rel) |
| |
| |
| def parse_args(): |
| parser = argparse.ArgumentParser('update_crate_tests') |
| parser.add_argument('paths', |
| nargs='*', |
| help='Absolute or relative paths of the projects as globs.') |
| parser.add_argument('--branch_and_commit', |
| action='store_true', |
| help='Starts a new branch and commit changes.') |
| parser.add_argument('--push_change', |
| action='store_true', |
| help='Pushes change to Gerrit.') |
| return parser.parse_args() |
| |
| def main(): |
| args = parse_args() |
| paths = args.paths if len(args.paths) > 0 else [os.getcwd()] |
| # We want to use glob to get all the paths, so we first convert to absolute. |
| paths = [Path(path).resolve() for path in paths] |
| paths = sorted([path for abs_path in paths |
| for path in glob.glob(str(abs_path))]) |
| |
| env = Env() |
| bazel = Bazel(env) |
| for path in paths: |
| try: |
| test_mapping = TestMapping(env, bazel, path) |
| test_mapping.create() |
| changed = (subprocess.call(['git', 'diff', '--quiet']) == 1) |
| untracked = (os.path.isfile('TEST_MAPPING') and |
| (subprocess.run(['git', 'ls-files', '--error-unmatch', 'TEST_MAPPING'], |
| stderr=subprocess.DEVNULL, |
| stdout=subprocess.DEVNULL).returncode == 1)) |
| if args.branch_and_commit and (changed or untracked): |
| subprocess.check_output(['repo', 'start', |
| 'tmp_auto_test_mapping', '.']) |
| subprocess.check_output(['git', 'add', 'TEST_MAPPING']) |
| # test_mapping_config.json is not always present |
| subprocess.call(['git', 'add', 'test_mapping_config.json'], |
| stderr=subprocess.DEVNULL, |
| stdout=subprocess.DEVNULL) |
| subprocess.check_output(['git', 'commit', '-m', |
| 'Update TEST_MAPPING\n\nTest: None']) |
| if args.push_change and (changed or untracked): |
| date = datetime.today().strftime('%m-%d') |
| subprocess.check_output(['git', 'push', 'aosp', 'HEAD:refs/for/master', |
| '-o', 'topic=test-mapping-%s' % date]) |
| except (UpdaterException, subprocess.CalledProcessError) as err: |
| sys.exit("Error: " + str(err)) |
| |
| if __name__ == '__main__': |
| main() |