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