1#!/usr/bin/env python3
2# Copyright 2018 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Validates TEST_MAPPING files in Android source code.
17
18The goal of this script is to validate the format of TEST_MAPPING files:
191. It must be a valid json file.
202. Each test group must have a list of test that containing name and options.
213. Each import must have only one key `path` and one value for the path to
22   import TEST_MAPPING files.
23"""
24
25import argparse
26import json
27import os
28import re
29import sys
30from typing import Any, Dict
31
32_path = os.path.realpath(__file__ + '/../..')
33if sys.path[0] != _path:
34    sys.path.insert(0, _path)
35del _path
36
37# We have to import our local modules after the sys.path tweak.  We can't use
38# relative imports because this is an executable program, not a module.
39# pylint: disable=wrong-import-position
40import rh.git
41
42_IMPORTS = 'imports'
43_NAME = 'name'
44_OPTIONS = 'options'
45_PATH = 'path'
46_HOST = 'host'
47_PREFERRED_TARGETS = 'preferred_targets'
48_FILE_PATTERNS = 'file_patterns'
49_INVALID_IMPORT_CONFIG = 'Invalid import config in TEST_MAPPING file'
50_INVALID_TEST_CONFIG = 'Invalid test config in TEST_MAPPING file'
51_TEST_MAPPING_URL = (
52    'https://source.android.com/compatibility/tests/development/'
53    'test-mapping')
54
55# Pattern used to identify line-level '//'-format comment in TEST_MAPPING file.
56_COMMENTS_RE = re.compile(r'^\s*//')
57
58
59class Error(Exception):
60    """Base exception for all custom exceptions in this module."""
61
62
63class InvalidTestMappingError(Error):
64    """Exception to raise when detecting an invalid TEST_MAPPING file."""
65
66
67def _filter_comments(json_data: str) -> str:
68    """Removes '//'-format comments in TEST_MAPPING file to valid format.
69
70    Args:
71        json_data: TEST_MAPPING file content (as a string).
72
73    Returns:
74        Valid json string without comments.
75    """
76    return ''.join(
77        '\n' if _COMMENTS_RE.match(x) else x for x in json_data.splitlines())
78
79
80def _validate_import(entry: Dict[str, Any], test_mapping_file: str):
81    """Validates an import setting.
82
83    Args:
84        entry: A dictionary of an import setting.
85        test_mapping_file: Path to the TEST_MAPPING file to be validated.
86
87    Raises:
88        InvalidTestMappingError: if the import setting is invalid.
89    """
90    if len(entry) != 1:
91        raise InvalidTestMappingError(
92            f'{_INVALID_IMPORT_CONFIG} {test_mapping_file}. Each import can '
93            f'only have one `path` setting. Failed entry: {entry}')
94    if _PATH not in entry:
95        raise InvalidTestMappingError(
96            f'{_INVALID_IMPORT_CONFIG} {test_mapping_file}. Import can '
97            f'only have one `path` setting. Failed entry: {entry}')
98
99
100def _validate_test(test: Dict[str, Any], test_mapping_file: str) -> bool:
101    """Returns whether a test declaration is valid.
102
103    Args:
104        test: A dictionary of a test declaration.
105        test_mapping_file: Path to the TEST_MAPPING file to be validated.
106
107    Raises:
108        InvalidTestMappingError: if the a test declaration is invalid.
109    """
110    if _NAME not in test:
111        raise InvalidTestMappingError(
112
113            f'{_INVALID_TEST_CONFIG} {test_mapping_file}. Test config must '
114            f'have a `name` setting. Failed test config: {test}')
115
116    if not isinstance(test.get(_HOST, False), bool):
117        raise InvalidTestMappingError(
118            f'{_INVALID_TEST_CONFIG} {test_mapping_file}. `host` setting in '
119            f'test config can only have boolean value of `true` or `false`. '
120            f'Failed test config: {test}')
121
122    for key in (_PREFERRED_TARGETS, _FILE_PATTERNS):
123        value = test.get(key, [])
124        if (not isinstance(value, list) or
125            any(not isinstance(t, str) for t in value)):
126            raise InvalidTestMappingError(
127                f'{_INVALID_TEST_CONFIG} {test_mapping_file}. `{key}` setting '
128                f'in test config can only be a list of strings. '
129                f'Failed test config: {test}')
130
131    for option in test.get(_OPTIONS, []):
132        if not isinstance(option, dict):
133            raise InvalidTestMappingError(
134                f'{_INVALID_TEST_CONFIG} {test_mapping_file}. Option setting '
135                f'in test config can only be a dictionary of key-val setting. '
136                f'Failed entry: {option}')
137        if len(option) != 1:
138            raise InvalidTestMappingError(
139                f'{_INVALID_TEST_CONFIG} {test_mapping_file}. Each option '
140                f'setting can only have one key-val setting. '
141                f'Failed entry: {option}')
142
143
144def process_file(test_mapping_file: str):
145    """Validates a TEST_MAPPING file content."""
146    try:
147        test_mapping_data = json.loads(_filter_comments(test_mapping_file))
148    except ValueError as exception:
149        # The file is not a valid JSON file.
150        print(
151            f'Invalid JSON data in TEST_MAPPING file '
152            f'Failed to parse JSON data: {test_mapping_file}, '
153            f'error: {exception}',
154            file=sys.stderr)
155        raise
156
157    for group, value in test_mapping_data.items():
158        if group == _IMPORTS:
159            # Validate imports.
160            for test in value:
161                _validate_import(test, test_mapping_file)
162        else:
163            # Validate tests.
164            for test in value:
165                _validate_test(test, test_mapping_file)
166
167
168def get_parser():
169    """Returns a command line parser."""
170    parser = argparse.ArgumentParser(description=__doc__)
171    parser.add_argument('--commit', type=str,
172                        help='Specify the commit to validate.')
173    parser.add_argument('project_dir')
174    parser.add_argument('files', nargs='+')
175    return parser
176
177
178def main(argv):
179    """Main function."""
180    parser = get_parser()
181    opts = parser.parse_args(argv)
182    try:
183        for filename in opts.files:
184            if opts.commit:
185                json_data = rh.git.get_file_content(opts.commit, filename)
186            else:
187                with open(os.path.join(opts.project_dir, filename)) as file:
188                    json_data = file.read()
189            process_file(json_data)
190    except:
191        print('Visit %s for details about the format of TEST_MAPPING '
192              'file.' % _TEST_MAPPING_URL, file=sys.stderr)
193        raise
194
195
196if __name__ == '__main__':
197    sys.exit(main(sys.argv[1:]))
198