blob: 77808592caa4432eaf5061dd0f1eab660fc8e281 [file] [log] [blame]
#!/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:]))