#!/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), encoding='utf-8') as file: json_data = file.read() process_file(json_data) except: print(f'Visit {_TEST_MAPPING_URL} for details about the format of ' 'TEST_MAPPING file.', file=sys.stderr) raise if __name__ == '__main__': sys.exit(main(sys.argv[1:]))