aboutsummaryrefslogtreecommitdiff
path: root/tools/android_test_mapping_format.py
blob: ae784cfc423ffb21166d51e454a51cfce5da5113 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
#!/usr/bin/env python3
# Copyright 2018 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Validates TEST_MAPPING files in Android source code.

The goal of this script is to validate the format of TEST_MAPPING files:
1. It must be a valid json file.
2. Each test group must have a list of test that containing name and options.
3. Each import must have only one key `path` and one value for the path to
   import TEST_MAPPING files.
"""

import argparse
import json
import os
import re
import sys
from typing import Any, Dict

_path = os.path.realpath(__file__ + '/../..')
if sys.path[0] != _path:
    sys.path.insert(0, _path)
del _path

# We have to import our local modules after the sys.path tweak.  We can't use
# relative imports because this is an executable program, not a module.
# pylint: disable=wrong-import-position
import rh.git

_IMPORTS = 'imports'
_NAME = 'name'
_OPTIONS = 'options'
_PATH = 'path'
_HOST = 'host'
_PREFERRED_TARGETS = 'preferred_targets'
_FILE_PATTERNS = 'file_patterns'
_INVALID_IMPORT_CONFIG = 'Invalid import config in TEST_MAPPING file'
_INVALID_TEST_CONFIG = 'Invalid test config in TEST_MAPPING file'
_TEST_MAPPING_URL = (
    'https://source.android.com/compatibility/tests/development/'
    'test-mapping')

# Pattern used to identify line-level '//'-format comment in TEST_MAPPING file.
_COMMENTS_RE = re.compile(r'^\s*//')


class Error(Exception):
    """Base exception for all custom exceptions in this module."""


class InvalidTestMappingError(Error):
    """Exception to raise when detecting an invalid TEST_MAPPING file."""


def _filter_comments(json_data: str) -> str:
    """Removes '//'-format comments in TEST_MAPPING file to valid format.

    Args:
        json_data: TEST_MAPPING file content (as a string).

    Returns:
        Valid json string without comments.
    """
    return ''.join(
        '\n' if _COMMENTS_RE.match(x) else x for x in json_data.splitlines())


def _validate_import(entry: Dict[str, Any], test_mapping_file: str):
    """Validates an import setting.

    Args:
        entry: A dictionary of an import setting.
        test_mapping_file: Path to the TEST_MAPPING file to be validated.

    Raises:
        InvalidTestMappingError: if the import setting is invalid.
    """
    if len(entry) != 1:
        raise InvalidTestMappingError(
            f'{_INVALID_IMPORT_CONFIG} {test_mapping_file}. Each import can '
            f'only have one `path` setting. Failed entry: {entry}')
    if _PATH not in entry:
        raise InvalidTestMappingError(
            f'{_INVALID_IMPORT_CONFIG} {test_mapping_file}. Import can '
            f'only have one `path` setting. Failed entry: {entry}')


def _validate_test(test: Dict[str, Any], test_mapping_file: str) -> bool:
    """Returns whether a test declaration is valid.

    Args:
        test: A dictionary of a test declaration.
        test_mapping_file: Path to the TEST_MAPPING file to be validated.

    Raises:
        InvalidTestMappingError: if the a test declaration is invalid.
    """
    if _NAME not in test:
        raise InvalidTestMappingError(

            f'{_INVALID_TEST_CONFIG} {test_mapping_file}. Test config must '
            f'have a `name` setting. Failed test config: {test}')

    if not isinstance(test.get(_HOST, False), bool):
        raise InvalidTestMappingError(
            f'{_INVALID_TEST_CONFIG} {test_mapping_file}. `host` setting in '
            f'test config can only have boolean value of `true` or `false`. '
            f'Failed test config: {test}')

    for key in (_PREFERRED_TARGETS, _FILE_PATTERNS):
        value = test.get(key, [])
        if (not isinstance(value, list) or
            any(not isinstance(t, str) for t in value)):
            raise InvalidTestMappingError(
                f'{_INVALID_TEST_CONFIG} {test_mapping_file}. `{key}` setting '
                f'in test config can only be a list of strings. '
                f'Failed test config: {test}')

    for option in test.get(_OPTIONS, []):
        if not isinstance(option, dict):
            raise InvalidTestMappingError(
                f'{_INVALID_TEST_CONFIG} {test_mapping_file}. Option setting '
                f'in test config can only be a dictionary of key-val setting. '
                f'Failed entry: {option}')
        if len(option) != 1:
            raise InvalidTestMappingError(
                f'{_INVALID_TEST_CONFIG} {test_mapping_file}. Each option '
                f'setting can only have one key-val setting. '
                f'Failed entry: {option}')


def process_file(test_mapping_file: str):
    """Validates a TEST_MAPPING file content."""
    try:
        test_mapping_data = json.loads(_filter_comments(test_mapping_file))
    except ValueError as exception:
        # The file is not a valid JSON file.
        print(
            f'Invalid JSON data in TEST_MAPPING file '
            f'Failed to parse JSON data: {test_mapping_file}, '
            f'error: {exception}',
            file=sys.stderr)
        raise

    for group, value in test_mapping_data.items():
        if group == _IMPORTS:
            # Validate imports.
            for test in value:
                _validate_import(test, test_mapping_file)
        else:
            # Validate tests.
            for test in value:
                _validate_test(test, test_mapping_file)


def get_parser():
    """Returns a command line parser."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--commit', type=str,
                        help='Specify the commit to validate.')
    parser.add_argument('project_dir')
    parser.add_argument('files', nargs='+')
    return parser


def main(argv):
    """Main function."""
    parser = get_parser()
    opts = parser.parse_args(argv)
    try:
        for filename in opts.files:
            if opts.commit:
                json_data = rh.git.get_file_content(opts.commit, filename)
            else:
                with open(os.path.join(opts.project_dir, filename)) as file:
                    json_data = file.read()
            process_file(json_data)
    except:
        print('Visit %s for details about the format of TEST_MAPPING '
              'file.' % _TEST_MAPPING_URL, file=sys.stderr)
        raise


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))