| #!/usr/bin/env python3 |
| # |
| # Copyright (c) 2023 Google LLC. All rights reserved. |
| # |
| # SPDX-License-Identifier: BSD-3-Clause |
| # |
| |
| """Generates the same output as generate_test_list.pl, but using python. |
| |
| Takes an xml file describing a list of testsuites as well as a skip list file |
| and outputs a src and header file that refers to those tests. |
| """ |
| |
| # This script was linted and formatted using the following commands: |
| # autoflake -ir --remove-all-unused-imports --expand-star-imports \ |
| # --remove-duplicate-keys --remove-unused-variables tools/generate_test_list/ |
| # isort tools/generate_test_list/ |
| # black tools/generate_test_list/ --line-length 100 |
| # flake8 tools/generate_test_list/ --max-line-length 100 |
| |
| import argparse |
| import os.path |
| import urllib.parse |
| import xml.etree.ElementInclude |
| import xml.parsers.expat |
| from dataclasses import dataclass |
| from typing import Dict, List |
| from xml.etree.ElementTree import Element, TreeBuilder |
| |
| TESTS_LIST_H_TPL_FILENAME = "tests_list.h.tpl" |
| TESTCASE_COUNT_TEMPLATE = "{{testcase_count}}" |
| |
| TESTS_LIST_C_TPL_FILENAME = "tests_list.c.tpl" |
| FUNCTION_PROTOTYPES_TEMPLATE = "{{function_prototypes}}" |
| TESTCASE_LISTS_TEMPLATE = "{{testcase_lists}}" |
| TESTSUITES_LIST_TEMPLATE = "{{testsuites_list}}" |
| |
| XINCLUDE_INCLUDE = "xi:include" |
| |
| MAX_EXPANSION_DEPTH = 5 |
| |
| # Intermediate repesentation classes. |
| |
| |
| @dataclass |
| class TestCase: |
| """Class representing a single TFTF test case.""" |
| |
| name: str |
| function: str |
| description: str = "" |
| |
| |
| @dataclass |
| class TestSuite: |
| """Class representing a single TFTF test suite.""" |
| |
| name: str |
| description: str |
| testcases: List[TestCase] |
| |
| |
| def find_element_with_name_or_return_none(iterable, name: str): |
| """Looks through iterable for an element whose 'name' field matches name.""" |
| return next(filter(lambda x: x.name == name, iterable), None) |
| |
| |
| def parse_testsuites_element_into_ir(root: Element) -> List[TestSuite]: |
| """Given the root of a parsed XML file, construct TestSuite objects.""" |
| testsuite_xml_elements = root.findall(".//testsuite") |
| |
| testsuites = [] |
| # Parse into IR |
| for testsuite in testsuite_xml_elements: |
| testcases = [] |
| for testcase in testsuite.findall("testcase"): |
| testcases += [ |
| TestCase( |
| testcase.get("name"), |
| testcase.get("function"), |
| testcase.get("description", default=""), |
| ) |
| ] |
| testsuites += [TestSuite(testsuite.get("name"), testsuite.get("description"), testcases)] |
| |
| return testsuites |
| |
| |
| # In order to keep this script standalone (meaning no libraries outside of the |
| # standard library), we have to do our own assembling of the XML Elements. This |
| # is necessary because python doesn't give us a nice way to support external |
| # entity expansion. As such we have to use the low level expat parser and build |
| # the tree using TreeBuilder. |
| |
| |
| def parse_xml_no_xinclude_expansion(filename: str) -> Element: |
| """Parse filename into an ElementTree.Element, following external entities.""" |
| xml_dir_root = os.path.dirname(filename) |
| with open(filename) as fobj: |
| xml_contents = fobj.read() |
| |
| parser = xml.parsers.expat.ParserCreate() |
| parser.SetParamEntityParsing(xml.parsers.expat.XML_PARAM_ENTITY_PARSING_ALWAYS) |
| |
| global treebuilder |
| treebuilder = TreeBuilder() |
| global expansion_depth |
| expansion_depth = 0 |
| |
| def start_element_handler(name: str, attributes): |
| # ElementInclude.include requires that the XInclude namespace is expanded. |
| if name == "xi:include": |
| name = "{http://www.w3.org/2001/XInclude}include" |
| treebuilder.start(name, attributes) |
| |
| def end_element_handler(name: str): |
| treebuilder.end(name) |
| |
| def external_entity_ref_handler(context, base, systemId, publicId): |
| global expansion_depth |
| |
| external_entity_parser = parser.ExternalEntityParserCreate(context, "utf-8") |
| assign_all_parser_callbacks(external_entity_parser) |
| with open(os.path.join(xml_dir_root, systemId)) as fobj: |
| sub_xml_contents = fobj.read() |
| expansion_depth += 1 |
| if expansion_depth > MAX_EXPANSION_DEPTH: |
| raise ValueError("Max entity expansion depth reached") |
| |
| external_entity_parser.Parse(sub_xml_contents, True) |
| expansion_depth -= 1 |
| return 1 |
| |
| def assign_all_parser_callbacks(p): |
| p.StartElementHandler = start_element_handler |
| p.EndElementHandler = end_element_handler |
| p.ExternalEntityRefHandler = external_entity_ref_handler |
| |
| assign_all_parser_callbacks(parser) |
| parser.Parse(xml_contents, True) |
| |
| return treebuilder.close() |
| |
| |
| # Older versions of python3 don't support ElementInclude.include's base_url |
| # kwarg. This callable class works around this. |
| # base_url allows XInclude paths relative to the toplevel XML file to be used. |
| class ElementIncludeLoaderAdapter: |
| """Adapts between ElementInclude's loader interface and our XML parser.""" |
| |
| def __init__(self, base_url: str): |
| self.base_url = base_url |
| |
| def __call__(self, href: str, parse: str): |
| if parse != "xml": |
| raise ValueError("'parse' must be 'xml'") |
| |
| return parse_xml_no_xinclude_expansion(urllib.parse.urljoin(self.base_url, href)) |
| |
| |
| def parse_testsuites_from_file(filename: str) -> List[TestSuite]: |
| """Given an XML file, parse the contents into a List[TestSuite].""" |
| root = parse_xml_no_xinclude_expansion(filename) |
| |
| base_url = os.path.abspath(filename) |
| loader = ElementIncludeLoaderAdapter(base_url) |
| xml.etree.ElementInclude.include(root, loader=loader) |
| |
| if root.tag == "testsuites": |
| testsuites_xml_elements = [root] |
| elif root.tag == "document": |
| testsuites_xml_elements = root.findall("testsuites") |
| else: |
| raise ValueError(f"Unexpected root tag '{root.tag}' in {filename}") |
| |
| testsuites = [] |
| |
| for testsuites_xml_element in testsuites_xml_elements: |
| testsuites += parse_testsuites_element_into_ir(testsuites_xml_element) |
| |
| return testsuites |
| |
| |
| def check_validity_of_names(testsuites: List[TestSuite]): |
| """Checks that all testsuite and testcase names are valid.""" |
| testsuite_name_set = set() |
| for ts in testsuites: |
| if "/" in ts.name: |
| raise ValueError(f"ERROR: {args.xml_test_filename}: Invalid test suite name {ts.name}") |
| |
| if ts.name in testsuite_name_set: |
| raise ValueError( |
| f"ERROR: {args.xml_test_filename}: Can't have 2 test suites named " f"{ts.name}" |
| ) |
| |
| testsuite_name_set.add(ts.name) |
| |
| testcase_name_set = set() |
| for tc in ts.testcases: |
| if tc.name in testcase_name_set: |
| raise ValueError( |
| f"ERROR: {args.xml_test_filename}: Can't have 2 tests named " f"{tc.name}" |
| ) |
| |
| testcase_name_set.add(tc.name) |
| |
| |
| def remove_skipped_tests(testsuites: List[TestSuite], skip_tests_filename: str): |
| """Remove skipped tests from testsuites based on skip_tests_filename.""" |
| with open(skip_tests_filename) as skipped_file: |
| skipped_file_lines = skipped_file.readlines() |
| for i, l in enumerate(skipped_file_lines): |
| line = l.strip() |
| |
| # Skip empty lines and comments |
| if not line or line[0] == "#": |
| continue |
| |
| testsuite_name, sep, testcase_name = line.partition("/") |
| |
| testsuite = find_element_with_name_or_return_none(testsuites, testsuite_name) |
| |
| if not testsuite: |
| print( |
| f"WARNING: {skip_tests_filename}:{i + 1}: Test suite " |
| f"'{testsuite_name}' doesn't exist or has already been deleted." |
| ) |
| continue |
| |
| if not testcase_name: |
| print(f"INFO: Test suite '{testsuite_name}' will be skipped") |
| testsuites = list(filter(lambda x: x.name != testsuite_name, testsuites)) |
| continue |
| |
| testcase = find_element_with_name_or_return_none(testsuite.testcases, testcase_name) |
| if not testcase: |
| print( |
| f"WARNING: {skip_tests_filename}:{i + 1}: Test case " |
| f"'{testsuite_name}/{testcase_name} doesn't exist or has already " |
| "been deleted" |
| ) |
| continue |
| |
| print(f"INFO: Test case '{testsuite_name}/{testcase_name}' will be skipped.") |
| testsuite.testcases.remove(testcase) |
| |
| return testsuites |
| |
| |
| def generate_function_prototypes(testcases: List[TestCase]): |
| """Generates function prototypes for the provided list of testcases.""" |
| return [f"test_result_t {t.function}(void);" for t in testcases] |
| |
| |
| def generate_testcase_lists(testsuites: List[TestSuite]): |
| """Generates the lists that enumerate the individual testcases in each testsuite.""" |
| testcase_lists_contents = [] |
| testcase_index = 0 |
| for i, testsuite in enumerate(testsuites): |
| testcase_lists_contents += [f"\nconst test_case_t testcases_{i}[] = {{"] |
| for testcase in testsuite.testcases: |
| testcase_lists_contents += [ |
| f' {{ {testcase_index}, "{testcase.name}", ' |
| f'"{testcase.description}", {testcase.function} }},' |
| ] |
| testcase_index += 1 |
| testcase_lists_contents += [" { 0, NULL, NULL, NULL }"] |
| testcase_lists_contents += ["};\n"] |
| |
| return testcase_lists_contents |
| |
| |
| def generate_testsuite_lists(testsuites: List[TestSuite]): |
| """Generates the list of testsuites.""" |
| testsuites_list_contents = [] |
| testsuites_list_contents += ["const test_suite_t testsuites[] = {"] |
| for i, testsuite in enumerate(testsuites): |
| testsuites_list_contents += [ |
| f' {{ "{testsuite.name}", "{testsuite.description}", testcases_{i} }},' |
| ] |
| testsuites_list_contents += [" { NULL, NULL, NULL }"] |
| testsuites_list_contents += ["};"] |
| return testsuites_list_contents |
| |
| |
| def generate_file_from_template( |
| template_filename: str, output_filename: str, template: Dict[str, str] |
| ): |
| """Given a template file, generate an output file based on template dictionary.""" |
| with open(template_filename) as template_fobj: |
| template_contents = template_fobj.read() |
| |
| output_contents = template_contents |
| for to_find, to_replace in template.items(): |
| output_contents = output_contents.replace(to_find, to_replace) |
| |
| with open(output_filename, "w") as output_fobj: |
| output_fobj.write(output_contents) |
| |
| |
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| "testlist_src_filename", |
| type=str, |
| help="Output source filename", |
| ) |
| parser.add_argument( |
| "testlist_hdr_filename", |
| type=str, |
| help="Output header filename", |
| ) |
| parser.add_argument("xml_test_filename", type=str, help="Input xml filename") |
| parser.add_argument( |
| "--plat-skip-file", |
| type=str, |
| help="Filename containing tests to skip for this platform", |
| dest="plat_skipped_list_filename", |
| required=False, |
| ) |
| parser.add_argument( |
| "--arch-skip-file", |
| type=str, |
| help="Filename containing tests to skip for this architecture", |
| dest="arch_skipped_list_filename", |
| required=False, |
| ) |
| args = parser.parse_args() |
| |
| testsuites = parse_testsuites_from_file(args.xml_test_filename) |
| |
| check_validity_of_names(testsuites) |
| |
| if args.plat_skipped_list_filename: |
| testsuites = remove_skipped_tests(testsuites, args.plat_skipped_list_filename) |
| |
| if args.arch_skipped_list_filename: |
| testsuites = remove_skipped_tests(testsuites, args.arch_skipped_list_filename) |
| |
| # Flatten all testcases |
| combined_testcases = [tc for ts in testsuites for tc in ts.testcases] |
| |
| # Generate header file |
| generate_file_from_template( |
| os.path.join(os.path.dirname(__file__), TESTS_LIST_H_TPL_FILENAME), |
| args.testlist_hdr_filename, |
| {TESTCASE_COUNT_TEMPLATE: str(len(combined_testcases))}, |
| ) |
| |
| # Generate the source file |
| all_function_prototypes = generate_function_prototypes(combined_testcases) |
| testcase_lists_contents = generate_testcase_lists(testsuites) |
| testsuites_list_contents = generate_testsuite_lists(testsuites) |
| |
| generate_file_from_template( |
| os.path.join(os.path.dirname(__file__), TESTS_LIST_C_TPL_FILENAME), |
| args.testlist_src_filename, |
| { |
| FUNCTION_PROTOTYPES_TEMPLATE: "\n".join(all_function_prototypes), |
| TESTCASE_LISTS_TEMPLATE: "\n".join(testcase_lists_contents), |
| TESTSUITES_LIST_TEMPLATE: "\n".join(testsuites_list_contents), |
| }, |
| ) |