1#!/usr/bin/env python3 2# 3# Copyright (C) 2020 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16"""Add or update tests to TEST_MAPPING. 17 18This script uses Bazel to find reverse dependencies on a crate and generates a 19TEST_MAPPING file. It accepts the absolute path to a crate as argument. If no 20argument is provided, it assumes the crate is the current directory. 21 22 Usage: 23 $ . build/envsetup.sh 24 $ lunch aosp_arm64-eng 25 $ update_crate_tests.py $ANDROID_BUILD_TOP/external/rust/crates/libc 26 27This script is automatically called by external_updater. 28""" 29 30import argparse 31import glob 32import json 33import os 34import platform 35import re 36import subprocess 37import sys 38from datetime import datetime 39from pathlib import Path 40 41# Some tests requires specific options. Consider fixing the upstream crate 42# before updating this dictionary. 43TEST_OPTIONS = { 44 "ring_test_tests_digest_tests": [{"test-timeout": "600000"}], 45 "ring_test_src_lib": [{"test-timeout": "100000"}], 46} 47 48# Groups to add tests to. "presubmit" runs x86_64 device tests+host tests, and 49# "presubmit-rust" runs arm64 device tests on physical devices. 50TEST_GROUPS = [ 51 "presubmit", 52 "presubmit-rust" 53] 54 55# Excluded tests. These tests will be ignored by this script. 56TEST_EXCLUDE = [ 57 "ash_test_src_lib", 58 "ash_test_tests_constant_size_arrays", 59 "ash_test_tests_display", 60 "shared_library_test_src_lib", 61 "vulkano_test_src_lib", 62 63 # These are helper binaries for aidl_integration_test 64 # and aren't actually meant to run as individual tests. 65 "aidl_test_rust_client", 66 "aidl_test_rust_service", 67 "aidl_test_rust_service_async", 68 69 # This is a helper binary for AuthFsHostTest and shouldn't 70 # be run directly. 71 "open_then_run", 72 73 # TODO: Remove when b/198197213 is closed. 74 "diced_client_test", 75] 76 77# Excluded modules. 78EXCLUDE_PATHS = [ 79 "//external/adhd", 80 "//external/crosvm", 81 "//external/libchromeos-rs", 82 "//external/vm_tools" 83] 84 85LABEL_PAT = re.compile('^//(.*):.*$') 86EXTERNAL_PAT = re.compile('^//external/rust/') 87 88 89class UpdaterException(Exception): 90 """Exception generated by this script.""" 91 92 93class Env(object): 94 """Env captures the execution environment. 95 96 It ensures this script is executed within an AOSP repository. 97 98 Attributes: 99 ANDROID_BUILD_TOP: A string representing the absolute path to the top 100 of the repository. 101 """ 102 def __init__(self): 103 try: 104 self.ANDROID_BUILD_TOP = os.environ['ANDROID_BUILD_TOP'] 105 except KeyError: 106 raise UpdaterException('$ANDROID_BUILD_TOP is not defined; you ' 107 'must first source build/envsetup.sh and ' 108 'select a target.') 109 110 111class Bazel(object): 112 """Bazel wrapper. 113 114 The wrapper is used to call bazel queryview and generate the list of 115 reverse dependencies. 116 117 Attributes: 118 path: The path to the bazel executable. 119 """ 120 def __init__(self, env): 121 """Constructor. 122 123 Note that the current directory is changed to ANDROID_BUILD_TOP. 124 125 Args: 126 env: An instance of Env. 127 128 Raises: 129 UpdaterException: an error occurred while calling soong_ui. 130 """ 131 if platform.system() != 'Linux': 132 raise UpdaterException('This script has only been tested on Linux.') 133 self.path = os.path.join(env.ANDROID_BUILD_TOP, "tools", "bazel") 134 soong_ui = os.path.join(env.ANDROID_BUILD_TOP, "build", "soong", "soong_ui.bash") 135 136 # soong_ui requires to be at the root of the repository. 137 os.chdir(env.ANDROID_BUILD_TOP) 138 print("Generating Bazel files...") 139 cmd = [soong_ui, "--make-mode", "bp2build"] 140 try: 141 subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True) 142 except subprocess.CalledProcessError as e: 143 raise UpdaterException('Unable to generate bazel workspace: ' + e.output) 144 145 print("Building Bazel Queryview. This can take a couple of minutes...") 146 cmd = [soong_ui, "--build-mode", "--all-modules", "--dir=.", "queryview"] 147 try: 148 subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True) 149 except subprocess.CalledProcessError as e: 150 raise UpdaterException('Unable to update TEST_MAPPING: ' + e.output) 151 152 def query_modules(self, path): 153 """Returns all modules for a given path.""" 154 cmd = self.path + " query --config=queryview /" + path + ":all" 155 out = subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True).strip().split("\n") 156 modules = set() 157 for line in out: 158 # speed up by excluding unused modules. 159 if "windows_x86" in line: 160 continue 161 modules.add(line) 162 return modules 163 164 def query_rdeps(self, module): 165 """Returns all reverse dependencies for a single module.""" 166 cmd = (self.path + " query --config=queryview \'rdeps(//..., " + 167 module + ")\' --output=label_kind") 168 out = (subprocess.check_output(cmd, shell=True, stderr=subprocess.DEVNULL, text=True) 169 .strip().split("\n")) 170 if '' in out: 171 out.remove('') 172 return out 173 174 def exclude_module(self, module): 175 for path in EXCLUDE_PATHS: 176 if module.startswith(path): 177 return True 178 return False 179 180 def query_rdep_tests_dirs(self, modules, path): 181 """Returns all reverse dependency tests for modules in this package.""" 182 rdep_tests = set() 183 rdep_dirs = set() 184 path_pat = re.compile("^/%s:.*$" % path) 185 for module in modules: 186 for rdep in self.query_rdeps(module): 187 rule_type, _, mod = rdep.split(" ") 188 if rule_type == "rust_test_" or rule_type == "rust_test": 189 if self.exclude_module(mod): 190 continue 191 path_match = path_pat.match(mod) 192 if path_match or not EXTERNAL_PAT.match(mod): 193 rdep_tests.add(mod.split(":")[1].split("--")[0]) 194 else: 195 label_match = LABEL_PAT.match(mod) 196 if label_match: 197 rdep_dirs.add(label_match.group(1)) 198 return (rdep_tests, rdep_dirs) 199 200 201class Package(object): 202 """A Bazel package. 203 204 Attributes: 205 dir: The absolute path to this package. 206 dir_rel: The relative path to this package. 207 rdep_tests: The list of computed reverse dependencies. 208 rdep_dirs: The list of computed reverse dependency directories. 209 """ 210 def __init__(self, path, env, bazel): 211 """Constructor. 212 213 Note that the current directory is changed to the package location when 214 called. 215 216 Args: 217 path: Path to the package. 218 env: An instance of Env. 219 bazel: An instance of Bazel. 220 221 Raises: 222 UpdaterException: the package does not appear to belong to the 223 current repository. 224 """ 225 self.dir = path 226 try: 227 self.dir_rel = self.dir.split(env.ANDROID_BUILD_TOP)[1] 228 except IndexError: 229 raise UpdaterException('The path ' + self.dir + ' is not under ' + 230 env.ANDROID_BUILD_TOP + '; You must be in the ' 231 'directory of a crate or pass its absolute path ' 232 'as the argument.') 233 234 # Move to the package_directory. 235 os.chdir(self.dir) 236 modules = bazel.query_modules(self.dir_rel) 237 (self.rdep_tests, self.rdep_dirs) = bazel.query_rdep_tests_dirs(modules, self.dir_rel) 238 239 def get_rdep_tests_dirs(self): 240 return (self.rdep_tests, self.rdep_dirs) 241 242 243class TestMapping(object): 244 """A TEST_MAPPING file. 245 246 Attributes: 247 package: The package associated with this TEST_MAPPING file. 248 """ 249 def __init__(self, env, bazel, path): 250 """Constructor. 251 252 Args: 253 env: An instance of Env. 254 bazel: An instance of Bazel. 255 path: The absolute path to the package. 256 """ 257 self.package = Package(path, env, bazel) 258 259 def create(self): 260 """Generates the TEST_MAPPING file.""" 261 (tests, dirs) = self.package.get_rdep_tests_dirs() 262 if not bool(tests) and not bool(dirs): 263 if os.path.isfile('TEST_MAPPING'): 264 os.remove('TEST_MAPPING') 265 return 266 test_mapping = self.tests_dirs_to_mapping(tests, dirs) 267 self.write_test_mapping(test_mapping) 268 269 def tests_dirs_to_mapping(self, tests, dirs): 270 """Translate the test list into a dictionary.""" 271 test_mapping = {"imports": []} 272 for test_group in TEST_GROUPS: 273 test_mapping[test_group] = [] 274 for test in tests: 275 if test in TEST_EXCLUDE: 276 continue 277 if test in TEST_OPTIONS: 278 test_mapping[test_group].append({"name": test, "options": TEST_OPTIONS[test]}) 279 else: 280 test_mapping[test_group].append({"name": test}) 281 test_mapping[test_group] = sorted(test_mapping[test_group], key=lambda t: t["name"]) 282 for dir in dirs: 283 test_mapping["imports"].append({"path": dir}) 284 test_mapping["imports"] = sorted(test_mapping["imports"], key=lambda t: t["path"]) 285 test_mapping = {section: entry for (section, entry) in test_mapping.items() if entry} 286 return test_mapping 287 288 def write_test_mapping(self, test_mapping): 289 """Writes the TEST_MAPPING file.""" 290 with open("TEST_MAPPING", "w") as json_file: 291 json_file.write("// Generated by update_crate_tests.py for tests that depend on this crate.\n") 292 json.dump(test_mapping, json_file, indent=2, separators=(',', ': '), sort_keys=True) 293 json_file.write("\n") 294 print("TEST_MAPPING successfully updated for %s!" % self.package.dir_rel) 295 296 297def parse_args(): 298 parser = argparse.ArgumentParser('update_crate_tests') 299 parser.add_argument('paths', 300 nargs='*', 301 help='Absolute or relative paths of the projects as globs.') 302 parser.add_argument('--branch_and_commit', 303 action='store_true', 304 help='Starts a new branch and commit changes.') 305 parser.add_argument('--push_change', 306 action='store_true', 307 help='Pushes change to Gerrit.') 308 return parser.parse_args() 309 310 311def main(): 312 args = parse_args() 313 paths = args.paths if len(args.paths) > 0 else [os.getcwd()] 314 # We want to use glob to get all the paths, so we first convert to absolute. 315 paths = [Path(path).resolve() for path in paths] 316 paths = sorted([path for abs_path in paths 317 for path in glob.glob(str(abs_path))]) 318 319 env = Env() 320 bazel = Bazel(env) 321 for path in paths: 322 try: 323 test_mapping = TestMapping(env, bazel, path) 324 test_mapping.create() 325 changed = (subprocess.call(['git', 'diff', '--quiet']) == 1) 326 untracked = (os.path.isfile('TEST_MAPPING') and 327 (subprocess.run(['git', 'ls-files', '--error-unmatch', 'TEST_MAPPING'], 328 stderr=subprocess.DEVNULL, 329 stdout=subprocess.DEVNULL).returncode == 1)) 330 if args.branch_and_commit and (changed or untracked): 331 subprocess.check_output(['repo', 'start', 332 'tmp_auto_test_mapping', '.']) 333 subprocess.check_output(['git', 'add', 'TEST_MAPPING']) 334 subprocess.check_output(['git', 'commit', '-m', 335 'Update TEST_MAPPING\n\nTest: None']) 336 if args.push_change and (changed or untracked): 337 date = datetime.today().strftime('%m-%d') 338 subprocess.check_output(['git', 'push', 'aosp', 'HEAD:refs/for/master', 339 '-o', 'topic=test-mapping-%s' % date]) 340 except (UpdaterException, subprocess.CalledProcessError) as err: 341 sys.exit("Error: " + str(err)) 342 343if __name__ == '__main__': 344 main() 345