| #!/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 | |
| from pathlib import Path | |
| import re | |
| import sys | |
| from typing import Any, Dict | |
| THIS_FILE = Path(__file__).resolve() | |
| THIS_DIR = THIS_FILE.parent | |
| sys.path.insert(0, str(THIS_DIR.parent)) | |
| # 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:])) |