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