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#pylint: disable=too-many-lines
16"""
17Command Line Translator for atest.
18"""
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(object):
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 = raw_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 expression.
244
245            Args:
246                match: The matched regex pattern
247
248            Returns:
249                "" if it matches _COMMENTS, otherwise original string.
250            """
251            line = match.group(0).strip()
252            return "" if any(map(line.startswith, _COMMENTS)) else line
253        with open(test_mapping_file) as json_file:
254            return re.sub(_COMMENTS_RE, _replace, json_file.read())
255
256    def _read_tests_in_test_mapping(self, test_mapping_file):
257        """Read tests from a TEST_MAPPING file.
258
259        Args:
260            test_mapping_file: Path to a TEST_MAPPING file.
261
262        Returns:
263            A tuple of (all_tests, imports), where
264            all_tests is a dictionary of all tests in the TEST_MAPPING file,
265                grouped by test group.
266            imports is a list of test_mapping.Import to include other test
267                mapping files.
268        """
269        all_tests = {}
270        imports = []
271        test_mapping_dict = json.loads(self.filter_comments(test_mapping_file))
272        for test_group_name, test_list in test_mapping_dict.items():
273            if test_group_name == constants.TEST_MAPPING_IMPORTS:
274                for import_detail in test_list:
275                    imports.append(
276                        test_mapping.Import(test_mapping_file, import_detail))
277            else:
278                grouped_tests = all_tests.setdefault(test_group_name, set())
279                tests = []
280                for test in test_list:
281                    if (self.enable_file_patterns and
282                            not test_mapping.is_match_file_patterns(
283                                test_mapping_file, test)):
284                        continue
285                    test_mod_info = self.mod_info.name_to_module_info.get(
286                        test['name'])
287                    if not test_mod_info:
288                        print('WARNING: %s is not a valid build target and '
289                              'may not be discoverable by TreeHugger. If you '
290                              'want to specify a class or test-package, '
291                              'please set \'name\' to the test module and use '
292                              '\'options\' to specify the right tests via '
293                              '\'include-filter\'.\nNote: this can also occur '
294                              'if the test module is not built for your '
295                              'current lunch target.\n' %
296                              atest_utils.colorize(test['name'], constants.RED))
297                    elif not any(x in test_mod_info['compatibility_suites'] for
298                                 x in constants.TEST_MAPPING_SUITES):
299                        print('WARNING: Please add %s to either suite: %s for '
300                              'this TEST_MAPPING file to work with TreeHugger.' %
301                              (atest_utils.colorize(test['name'],
302                                                    constants.RED),
303                               atest_utils.colorize(constants.TEST_MAPPING_SUITES,
304                                                    constants.GREEN)))
305                    tests.append(test_mapping.TestDetail(test))
306                grouped_tests.update(tests)
307        return all_tests, imports
308
309    def _find_files(self, path, file_name=constants.TEST_MAPPING):
310        """Find all files with given name under the given path.
311
312        Args:
313            path: A string of path in source.
314
315        Returns:
316            A list of paths of the files with the matching name under the given
317            path.
318        """
319        test_mapping_files = []
320        for root, _, filenames in os.walk(path):
321            for filename in fnmatch.filter(filenames, file_name):
322                test_mapping_files.append(os.path.join(root, filename))
323        return test_mapping_files
324
325    def _get_tests_from_test_mapping_files(
326            self, test_group, test_mapping_files):
327        """Get tests in the given test mapping files with the match group.
328
329        Args:
330            test_group: Group of tests to run. Default is set to `presubmit`.
331            test_mapping_files: A list of path of TEST_MAPPING files.
332
333        Returns:
334            A tuple of (tests, all_tests, imports), where,
335            tests is a set of tests (test_mapping.TestDetail) defined in
336            TEST_MAPPING file of the given path, and its parent directories,
337            with matching test_group.
338            all_tests is a dictionary of all tests in TEST_MAPPING files,
339            grouped by test group.
340            imports is a list of test_mapping.Import objects that contains the
341            details of where to import a TEST_MAPPING file.
342        """
343        all_imports = []
344        # Read and merge the tests in all TEST_MAPPING files.
345        merged_all_tests = {}
346        for test_mapping_file in test_mapping_files:
347            all_tests, imports = self._read_tests_in_test_mapping(
348                test_mapping_file)
349            all_imports.extend(imports)
350            for test_group_name, test_list in all_tests.items():
351                grouped_tests = merged_all_tests.setdefault(
352                    test_group_name, set())
353                grouped_tests.update(test_list)
354
355        tests = set(merged_all_tests.get(test_group, []))
356        if test_group == constants.TEST_GROUP_ALL:
357            for grouped_tests in merged_all_tests.values():
358                tests.update(grouped_tests)
359        return tests, merged_all_tests, all_imports
360
361    # pylint: disable=too-many-arguments
362    # pylint: disable=too-many-locals
363    def _find_tests_by_test_mapping(
364            self, path='', test_group=constants.TEST_GROUP_PRESUBMIT,
365            file_name=constants.TEST_MAPPING, include_subdirs=False,
366            checked_files=None):
367        """Find tests defined in TEST_MAPPING in the given path.
368
369        Args:
370            path: A string of path in source. Default is set to '', i.e., CWD.
371            test_group: Group of tests to run. Default is set to `presubmit`.
372            file_name: Name of TEST_MAPPING file. Default is set to
373                `TEST_MAPPING`. The argument is added for testing purpose.
374            include_subdirs: True to include tests in TEST_MAPPING files in sub
375                directories.
376            checked_files: Paths of TEST_MAPPING files that have been checked.
377
378        Returns:
379            A tuple of (tests, all_tests), where,
380            tests is a set of tests (test_mapping.TestDetail) defined in
381            TEST_MAPPING file of the given path, and its parent directories,
382            with matching test_group.
383            all_tests is a dictionary of all tests in TEST_MAPPING files,
384            grouped by test group.
385        """
386        path = os.path.realpath(path)
387        test_mapping_files = set()
388        all_tests = {}
389        test_mapping_file = os.path.join(path, file_name)
390        if os.path.exists(test_mapping_file):
391            test_mapping_files.add(test_mapping_file)
392        # Include all TEST_MAPPING files in sub-directories if `include_subdirs`
393        # is set to True.
394        if include_subdirs:
395            test_mapping_files.update(self._find_files(path, file_name))
396        # Include all possible TEST_MAPPING files in parent directories.
397        root_dir = os.environ.get(constants.ANDROID_BUILD_TOP, os.sep)
398        while path != root_dir and path != os.sep:
399            path = os.path.dirname(path)
400            test_mapping_file = os.path.join(path, file_name)
401            if os.path.exists(test_mapping_file):
402                test_mapping_files.add(test_mapping_file)
403
404        if checked_files is None:
405            checked_files = set()
406        test_mapping_files.difference_update(checked_files)
407        checked_files.update(test_mapping_files)
408        if not test_mapping_files:
409            return test_mapping_files, all_tests
410
411        tests, all_tests, imports = self._get_tests_from_test_mapping_files(
412            test_group, test_mapping_files)
413
414        # Load TEST_MAPPING files from imports recursively.
415        if imports:
416            for import_detail in imports:
417                path = import_detail.get_path()
418                # (b/110166535 #19) Import path might not exist if a project is
419                # located in different directory in different branches.
420                if path is None:
421                    logging.warn(
422                        'Failed to import TEST_MAPPING at %s', import_detail)
423                    continue
424                # Search for tests based on the imported search path.
425                import_tests, import_all_tests = (
426                    self._find_tests_by_test_mapping(
427                        path, test_group, file_name, include_subdirs,
428                        checked_files))
429                # Merge the collections
430                tests.update(import_tests)
431                for group, grouped_tests in import_all_tests.items():
432                    all_tests.setdefault(group, set()).update(grouped_tests)
433
434        return tests, all_tests
435
436    def _gather_build_targets(self, test_infos):
437        targets = set()
438        for test_info in test_infos:
439            targets |= test_info.build_targets
440        return targets
441
442    def _get_test_mapping_tests(self, args):
443        """Find the tests in TEST_MAPPING files.
444
445        Args:
446            args: arg parsed object.
447
448        Returns:
449            A tuple of (test_names, test_details_list), where
450            test_names: a list of test name
451            test_details_list: a list of test_mapping.TestDetail objects for
452                the tests in TEST_MAPPING files with matching test group.
453        """
454        # Pull out tests from test mapping
455        src_path = ''
456        test_group = constants.TEST_GROUP_PRESUBMIT
457        if args.tests:
458            if ':' in args.tests[0]:
459                src_path, test_group = args.tests[0].split(':')
460            else:
461                src_path = args.tests[0]
462
463        test_details, all_test_details = self._find_tests_by_test_mapping(
464            path=src_path, test_group=test_group,
465            include_subdirs=args.include_subdirs, checked_files=set())
466        test_details_list = list(test_details)
467        if not test_details_list:
468            logging.warn(
469                'No tests of group `%s` found in TEST_MAPPING at %s or its '
470                'parent directories.\nYou might be missing atest arguments,'
471                ' try `atest --help` for more information',
472                test_group, os.path.realpath(''))
473            if all_test_details:
474                tests = ''
475                for test_group, test_list in all_test_details.items():
476                    tests += '%s:\n' % test_group
477                    for test_detail in sorted(test_list):
478                        tests += '\t%s\n' % test_detail
479                logging.warn(
480                    'All available tests in TEST_MAPPING files are:\n%s',
481                    tests)
482            metrics_utils.send_exit_event(constants.EXIT_CODE_TEST_NOT_FOUND)
483            sys.exit(constants.EXIT_CODE_TEST_NOT_FOUND)
484
485        logging.debug(
486            'Test details:\n%s',
487            '\n'.join([str(detail) for detail in test_details_list]))
488        test_names = [detail.name for detail in test_details_list]
489        return test_names, test_details_list
490
491
492    def translate(self, args):
493        """Translate atest command line into build targets and run commands.
494
495        Args:
496            args: arg parsed object.
497
498        Returns:
499            A tuple with set of build_target strings and list of TestInfos.
500        """
501        tests = args.tests
502        # Test details from TEST_MAPPING files
503        test_details_list = None
504        if atest_utils.is_test_mapping(args):
505            if args.enable_file_patterns:
506                self.enable_file_patterns = True
507            tests, test_details_list = self._get_test_mapping_tests(args)
508        atest_utils.colorful_print("\nFinding Tests...", constants.CYAN)
509        logging.debug('Finding Tests: %s', tests)
510        start = time.time()
511        test_infos = self._get_test_infos(tests, test_details_list)
512        logging.debug('Found tests in %ss', time.time() - start)
513        for test_info in test_infos:
514            logging.debug('%s\n', test_info)
515        build_targets = self._gather_build_targets(test_infos)
516        return build_targets, test_infos
517