1# Copyright 2017, The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Command Line Translator for atest.""" 16 17# pylint: disable=line-too-long 18# pylint: disable=too-many-lines 19 20from __future__ import print_function 21 22import fnmatch 23import json 24import logging 25import os 26import re 27import sys 28import time 29 30import atest_error 31import atest_utils 32import constants 33import test_finder_handler 34import test_mapping 35 36from metrics import metrics 37from metrics import metrics_utils 38from test_finders import module_finder 39 40FUZZY_FINDER = 'FUZZY' 41CACHE_FINDER = 'CACHE' 42 43# Pattern used to identify comments start with '//' or '#' in TEST_MAPPING. 44_COMMENTS_RE = re.compile(r'(?m)[\s\t]*(#|//).*|(\".*?\")') 45_COMMENTS = frozenset(['//', '#']) 46 47#pylint: disable=no-self-use 48class CLITranslator: 49 """ 50 CLITranslator class contains public method translate() and some private 51 helper methods. The atest tool can call the translate() method with a list 52 of strings, each string referencing a test to run. Translate() will 53 "translate" this list of test strings into a list of build targets and a 54 list of TradeFederation run commands. 55 56 Translation steps for a test string reference: 57 1. Narrow down the type of reference the test string could be, i.e. 58 whether it could be referencing a Module, Class, Package, etc. 59 2. Try to find the test files assuming the test string is one of these 60 types of reference. 61 3. If test files found, generate Build Targets and the Run Command. 62 """ 63 64 def __init__(self, module_info=None, print_cache_msg=True): 65 """CLITranslator constructor 66 67 Args: 68 module_info: ModuleInfo class that has cached module-info.json. 69 print_cache_msg: Boolean whether printing clear cache message or not. 70 True will print message while False won't print. 71 """ 72 self.mod_info = module_info 73 self.enable_file_patterns = False 74 self.msg = '' 75 if print_cache_msg: 76 self.msg = ('(Test info has been cached for speeding up the next ' 77 'run, if test info need to be updated, please add -c ' 78 'to clean the old cache.)') 79 80 # pylint: disable=too-many-locals 81 def _find_test_infos(self, test, tm_test_detail): 82 """Return set of TestInfos based on a given test. 83 84 Args: 85 test: A string representing test references. 86 tm_test_detail: The TestDetail of test configured in TEST_MAPPING 87 files. 88 89 Returns: 90 Set of TestInfos based on the given test. 91 """ 92 test_infos = set() 93 test_find_starts = time.time() 94 test_found = False 95 test_finders = [] 96 test_info_str = '' 97 find_test_err_msg = None 98 for finder in test_finder_handler.get_find_methods_for_test( 99 self.mod_info, test): 100 # For tests in TEST_MAPPING, find method is only related to 101 # test name, so the details can be set after test_info object 102 # is created. 103 try: 104 found_test_infos = finder.find_method( 105 finder.test_finder_instance, test) 106 except atest_error.TestDiscoveryException as e: 107 find_test_err_msg = e 108 if found_test_infos: 109 finder_info = finder.finder_info 110 for test_info in found_test_infos: 111 if tm_test_detail: 112 test_info.data[constants.TI_MODULE_ARG] = ( 113 tm_test_detail.options) 114 test_info.from_test_mapping = True 115 test_info.host = tm_test_detail.host 116 if finder_info != CACHE_FINDER: 117 test_info.test_finder = finder_info 118 test_infos.add(test_info) 119 test_found = True 120 print("Found '%s' as %s" % ( 121 atest_utils.colorize(test, constants.GREEN), 122 finder_info)) 123 if finder_info == CACHE_FINDER and test_infos: 124 test_finders.append(list(test_infos)[0].test_finder) 125 test_finders.append(finder_info) 126 test_info_str = ','.join([str(x) for x in found_test_infos]) 127 break 128 if not test_found: 129 f_results = self._fuzzy_search_and_msg(test, find_test_err_msg) 130 if f_results: 131 test_infos.update(f_results) 132 test_found = True 133 test_finders.append(FUZZY_FINDER) 134 metrics.FindTestFinishEvent( 135 duration=metrics_utils.convert_duration( 136 time.time() - test_find_starts), 137 success=test_found, 138 test_reference=test, 139 test_finders=test_finders, 140 test_info=test_info_str) 141 # Cache test_infos by default except running with TEST_MAPPING which may 142 # include customized flags and they are likely to mess up other 143 # non-test_mapping tests. 144 if test_infos and not tm_test_detail: 145 atest_utils.update_test_info_cache(test, test_infos) 146 print(self.msg) 147 return test_infos 148 149 def _fuzzy_search_and_msg(self, test, find_test_err_msg): 150 """ Fuzzy search and print message. 151 152 Args: 153 test: A string representing test references 154 find_test_err_msg: A string of find test error message. 155 156 Returns: 157 A list of TestInfos if found, otherwise None. 158 """ 159 print('No test found for: %s' % 160 atest_utils.colorize(test, constants.RED)) 161 # Currently we focus on guessing module names. Append names on 162 # results if more finders support fuzzy searching. 163 mod_finder = module_finder.ModuleFinder(self.mod_info) 164 results = mod_finder.get_fuzzy_searching_results(test) 165 if len(results) == 1 and self._confirm_running(results): 166 found_test_infos = mod_finder.find_test_by_module_name(results[0]) 167 # found_test_infos is a list with at most 1 element. 168 if found_test_infos: 169 return found_test_infos 170 elif len(results) > 1: 171 self._print_fuzzy_searching_results(results) 172 else: 173 print('No matching result for {0}.'.format(test)) 174 if find_test_err_msg: 175 print('%s\n' % (atest_utils.colorize( 176 find_test_err_msg, constants.MAGENTA))) 177 else: 178 print('(This can happen after a repo sync or if the test' 179 ' is new. Running: with "%s" may resolve the issue.)' 180 '\n' % (atest_utils.colorize( 181 constants.REBUILD_MODULE_INFO_FLAG, 182 constants.RED))) 183 return None 184 185 def _get_test_infos(self, tests, test_mapping_test_details=None): 186 """Return set of TestInfos based on passed in tests. 187 188 Args: 189 tests: List of strings representing test references. 190 test_mapping_test_details: List of TestDetail for tests configured 191 in TEST_MAPPING files. 192 193 Returns: 194 Set of TestInfos based on the passed in tests. 195 """ 196 test_infos = set() 197 if not test_mapping_test_details: 198 test_mapping_test_details = [None] * len(tests) 199 for test, tm_test_detail in zip(tests, test_mapping_test_details): 200 found_test_infos = self._find_test_infos(test, tm_test_detail) 201 test_infos.update(found_test_infos) 202 return test_infos 203 204 def _confirm_running(self, results): 205 """Listen to an answer from raw input. 206 207 Args: 208 results: A list of results. 209 210 Returns: 211 True is the answer is affirmative. 212 """ 213 decision = input('Did you mean {0}? [Y/n] '.format( 214 atest_utils.colorize(results[0], constants.GREEN))) 215 return decision in constants.AFFIRMATIVES 216 217 def _print_fuzzy_searching_results(self, results): 218 """Print modules when fuzzy searching gives multiple results. 219 220 If the result is lengthy, just print the first 10 items only since we 221 have already given enough-accurate result. 222 223 Args: 224 results: A list of guessed testable module names. 225 226 """ 227 atest_utils.colorful_print('Did you mean the following modules?', 228 constants.WHITE) 229 for mod in results[:10]: 230 atest_utils.colorful_print(mod, constants.GREEN) 231 232 def filter_comments(self, test_mapping_file): 233 """Remove comments in TEST_MAPPING file to valid format. Only '//' and 234 '#' are regarded as comments. 235 236 Args: 237 test_mapping_file: Path to a TEST_MAPPING file. 238 239 Returns: 240 Valid json string without comments. 241 """ 242 def _replace(match): 243 """Replace comments if found matching the defined regular 244 expression. 245 246 Args: 247 match: The matched regex pattern 248 249 Returns: 250 "" if it matches _COMMENTS, otherwise original string. 251 """ 252 line = match.group(0).strip() 253 return "" if any(map(line.startswith, _COMMENTS)) else line 254 with open(test_mapping_file) as json_file: 255 return re.sub(_COMMENTS_RE, _replace, json_file.read()) 256 257 def _read_tests_in_test_mapping(self, test_mapping_file): 258 """Read tests from a TEST_MAPPING file. 259 260 Args: 261 test_mapping_file: Path to a TEST_MAPPING file. 262 263 Returns: 264 A tuple of (all_tests, imports), where 265 all_tests is a dictionary of all tests in the TEST_MAPPING file, 266 grouped by test group. 267 imports is a list of test_mapping.Import to include other test 268 mapping files. 269 """ 270 all_tests = {} 271 imports = [] 272 test_mapping_dict = json.loads(self.filter_comments(test_mapping_file)) 273 for test_group_name, test_list in test_mapping_dict.items(): 274 if test_group_name == constants.TEST_MAPPING_IMPORTS: 275 for import_detail in test_list: 276 imports.append( 277 test_mapping.Import(test_mapping_file, import_detail)) 278 else: 279 grouped_tests = all_tests.setdefault(test_group_name, set()) 280 tests = [] 281 for test in test_list: 282 if (self.enable_file_patterns and 283 not test_mapping.is_match_file_patterns( 284 test_mapping_file, test)): 285 continue 286 test_mod_info = self.mod_info.name_to_module_info.get( 287 test['name']) 288 if not test_mod_info: 289 print('WARNING: %s is not a valid build target and ' 290 'may not be discoverable by TreeHugger. If you ' 291 'want to specify a class or test-package, ' 292 'please set \'name\' to the test module and use ' 293 '\'options\' to specify the right tests via ' 294 '\'include-filter\'.\nNote: this can also occur ' 295 'if the test module is not built for your ' 296 'current lunch target.\n' % 297 atest_utils.colorize(test['name'], constants.RED)) 298 elif not any(x in test_mod_info['compatibility_suites'] for 299 x in constants.TEST_MAPPING_SUITES): 300 print('WARNING: Please add %s to either suite: %s for ' 301 'this TEST_MAPPING file to work with TreeHugger.' % 302 (atest_utils.colorize(test['name'], 303 constants.RED), 304 atest_utils.colorize(constants.TEST_MAPPING_SUITES, 305 constants.GREEN))) 306 tests.append(test_mapping.TestDetail(test)) 307 grouped_tests.update(tests) 308 return all_tests, imports 309 310 def _find_files(self, path, file_name=constants.TEST_MAPPING): 311 """Find all files with given name under the given path. 312 313 Args: 314 path: A string of path in source. 315 316 Returns: 317 A list of paths of the files with the matching name under the given 318 path. 319 """ 320 test_mapping_files = [] 321 for root, _, filenames in os.walk(path): 322 for filename in fnmatch.filter(filenames, file_name): 323 test_mapping_files.append(os.path.join(root, filename)) 324 return test_mapping_files 325 326 def _get_tests_from_test_mapping_files( 327 self, test_group, test_mapping_files): 328 """Get tests in the given test mapping files with the match group. 329 330 Args: 331 test_group: Group of tests to run. Default is set to `presubmit`. 332 test_mapping_files: A list of path of TEST_MAPPING files. 333 334 Returns: 335 A tuple of (tests, all_tests, imports), where, 336 tests is a set of tests (test_mapping.TestDetail) defined in 337 TEST_MAPPING file of the given path, and its parent directories, 338 with matching test_group. 339 all_tests is a dictionary of all tests in TEST_MAPPING files, 340 grouped by test group. 341 imports is a list of test_mapping.Import objects that contains the 342 details of where to import a TEST_MAPPING file. 343 """ 344 all_imports = [] 345 # Read and merge the tests in all TEST_MAPPING files. 346 merged_all_tests = {} 347 for test_mapping_file in test_mapping_files: 348 all_tests, imports = self._read_tests_in_test_mapping( 349 test_mapping_file) 350 all_imports.extend(imports) 351 for test_group_name, test_list in all_tests.items(): 352 grouped_tests = merged_all_tests.setdefault( 353 test_group_name, set()) 354 grouped_tests.update(test_list) 355 356 tests = set(merged_all_tests.get(test_group, [])) 357 if test_group == constants.TEST_GROUP_ALL: 358 for grouped_tests in merged_all_tests.values(): 359 tests.update(grouped_tests) 360 return tests, merged_all_tests, all_imports 361 362 # pylint: disable=too-many-arguments 363 # pylint: disable=too-many-locals 364 def _find_tests_by_test_mapping( 365 self, path='', test_group=constants.TEST_GROUP_PRESUBMIT, 366 file_name=constants.TEST_MAPPING, include_subdirs=False, 367 checked_files=None): 368 """Find tests defined in TEST_MAPPING in the given path. 369 370 Args: 371 path: A string of path in source. Default is set to '', i.e., CWD. 372 test_group: Group of tests to run. Default is set to `presubmit`. 373 file_name: Name of TEST_MAPPING file. Default is set to 374 `TEST_MAPPING`. The argument is added for testing purpose. 375 include_subdirs: True to include tests in TEST_MAPPING files in sub 376 directories. 377 checked_files: Paths of TEST_MAPPING files that have been checked. 378 379 Returns: 380 A tuple of (tests, all_tests), where, 381 tests is a set of tests (test_mapping.TestDetail) defined in 382 TEST_MAPPING file of the given path, and its parent directories, 383 with matching test_group. 384 all_tests is a dictionary of all tests in TEST_MAPPING files, 385 grouped by test group. 386 """ 387 path = os.path.realpath(path) 388 test_mapping_files = set() 389 all_tests = {} 390 test_mapping_file = os.path.join(path, file_name) 391 if os.path.exists(test_mapping_file): 392 test_mapping_files.add(test_mapping_file) 393 # Include all TEST_MAPPING files in sub-directories if `include_subdirs` 394 # is set to True. 395 if include_subdirs: 396 test_mapping_files.update(self._find_files(path, file_name)) 397 # Include all possible TEST_MAPPING files in parent directories. 398 root_dir = os.environ.get(constants.ANDROID_BUILD_TOP, os.sep) 399 while path not in (root_dir, os.sep): 400 path = os.path.dirname(path) 401 test_mapping_file = os.path.join(path, file_name) 402 if os.path.exists(test_mapping_file): 403 test_mapping_files.add(test_mapping_file) 404 405 if checked_files is None: 406 checked_files = set() 407 test_mapping_files.difference_update(checked_files) 408 checked_files.update(test_mapping_files) 409 if not test_mapping_files: 410 return test_mapping_files, all_tests 411 412 tests, all_tests, imports = self._get_tests_from_test_mapping_files( 413 test_group, test_mapping_files) 414 415 # Load TEST_MAPPING files from imports recursively. 416 if imports: 417 for import_detail in imports: 418 path = import_detail.get_path() 419 # (b/110166535 #19) Import path might not exist if a project is 420 # located in different directory in different branches. 421 if path is None: 422 logging.warning( 423 'Failed to import TEST_MAPPING at %s', import_detail) 424 continue 425 # Search for tests based on the imported search path. 426 import_tests, import_all_tests = ( 427 self._find_tests_by_test_mapping( 428 path, test_group, file_name, include_subdirs, 429 checked_files)) 430 # Merge the collections 431 tests.update(import_tests) 432 for group, grouped_tests in import_all_tests.items(): 433 all_tests.setdefault(group, set()).update(grouped_tests) 434 435 return tests, all_tests 436 437 def _gather_build_targets(self, test_infos): 438 targets = set() 439 for test_info in test_infos: 440 targets |= test_info.build_targets 441 return targets 442 443 def _get_test_mapping_tests(self, args): 444 """Find the tests in TEST_MAPPING files. 445 446 Args: 447 args: arg parsed object. 448 449 Returns: 450 A tuple of (test_names, test_details_list), where 451 test_names: a list of test name 452 test_details_list: a list of test_mapping.TestDetail objects for 453 the tests in TEST_MAPPING files with matching test group. 454 """ 455 # Pull out tests from test mapping 456 src_path = '' 457 test_group = constants.TEST_GROUP_PRESUBMIT 458 if args.tests: 459 if ':' in args.tests[0]: 460 src_path, test_group = args.tests[0].split(':') 461 else: 462 src_path = args.tests[0] 463 464 test_details, all_test_details = self._find_tests_by_test_mapping( 465 path=src_path, test_group=test_group, 466 include_subdirs=args.include_subdirs, checked_files=set()) 467 test_details_list = list(test_details) 468 if not test_details_list: 469 logging.warning( 470 'No tests of group `%s` found in TEST_MAPPING at %s or its ' 471 'parent directories.\nYou might be missing atest arguments,' 472 ' try `atest --help` for more information', 473 test_group, os.path.realpath('')) 474 if all_test_details: 475 tests = '' 476 for test_group, test_list in all_test_details.items(): 477 tests += '%s:\n' % test_group 478 for test_detail in sorted(test_list): 479 tests += '\t%s\n' % test_detail 480 logging.warning( 481 'All available tests in TEST_MAPPING files are:\n%s', 482 tests) 483 metrics_utils.send_exit_event(constants.EXIT_CODE_TEST_NOT_FOUND) 484 sys.exit(constants.EXIT_CODE_TEST_NOT_FOUND) 485 486 logging.debug( 487 'Test details:\n%s', 488 '\n'.join([str(detail) for detail in test_details_list])) 489 test_names = [detail.name for detail in test_details_list] 490 return test_names, test_details_list 491 492 493 def translate(self, args): 494 """Translate atest command line into build targets and run commands. 495 496 Args: 497 args: arg parsed object. 498 499 Returns: 500 A tuple with set of build_target strings and list of TestInfos. 501 """ 502 tests = args.tests 503 # Test details from TEST_MAPPING files 504 test_details_list = None 505 if atest_utils.is_test_mapping(args): 506 if args.enable_file_patterns: 507 self.enable_file_patterns = True 508 tests, test_details_list = self._get_test_mapping_tests(args) 509 atest_utils.colorful_print("\nFinding Tests...", constants.CYAN) 510 logging.debug('Finding Tests: %s', tests) 511 start = time.time() 512 test_infos = self._get_test_infos(tests, test_details_list) 513 logging.debug('Found tests in %ss', time.time() - start) 514 for test_info in test_infos: 515 logging.debug('%s\n', test_info) 516 build_targets = self._gather_build_targets(test_infos) 517 return build_targets, test_infos 518