|  | #!/usr/bin/env python | 
|  | # Copyright 2021 The IREE Authors | 
|  | # | 
|  | # Licensed under the Apache License v2.0 with LLVM Exceptions. | 
|  | # See https://llvm.org/LICENSE.txt for license information. | 
|  | # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception | 
|  | """Generates runners for a matrix of test configurations. | 
|  |  | 
|  | This tool is used for sources under test which should be run in a matrix of | 
|  | different real-world configurations. It is aimed at integration tests which | 
|  | typically need a greater degree of flexibility and less "framework-ness" | 
|  | compared to (for example), lit-tests or more classic unit tests. | 
|  |  | 
|  | As with all modern things, it starts with directories that contain | 
|  | test_matrix.yaml files, which describe tests. | 
|  |  | 
|  | Conceptually, the test_matrix.yaml file declares a list of test_groups | 
|  | at the top-level. Each test group has an interpolated "id" which is unique | 
|  | in the scope of all tests. Example: | 
|  |  | 
|  | test_groups: | 
|  | - id: "math_real_{backend}_vs_{ref_backend}_{function}_dyndims_{dyndims}" | 
|  |  | 
|  | Interpolation is done relative to placeholders declared in a "matrix", where | 
|  | one concrete test is instantiated for the cartesian product of all combinations | 
|  | in the matrix. Here is an example matrix: | 
|  |  | 
|  | matrix: | 
|  | backend: *BACKENDS | 
|  | ref_backend: *REF_BACKENDS | 
|  | function: *MATH_FUNCTIONS | 
|  | dyndims: ["true", "false"] | 
|  |  | 
|  | Each key in the matrix expands to a list (or list of lists that are flattened) | 
|  | of string values. Note in the above that we have used YAML references to | 
|  | include fragments defined elsewhere in the file. | 
|  |  | 
|  | Each concrete test is then generated according to one or more runners, which | 
|  | are responsible for creating actual generated test sources. Example: | 
|  |  | 
|  | runner: | 
|  | - type: tfhost | 
|  | main: math_test.py | 
|  | args: | 
|  | - "--functions={function}" | 
|  | - "--target_backends={backend}" | 
|  | - "--reference_backend={ref_backend}" | 
|  | - "--dynamic_dims={dyndims}" | 
|  |  | 
|  | The type of runner is a hard-coded feature of this script. See the | 
|  | implementations at the end. | 
|  |  | 
|  | By default, every expanded combination is included in the test suite and is | 
|  | expected to pass. This can be changed by including 'xfail' and/or 'xpass' | 
|  | lists. A test will be marked expected-failing if it matches any of the | 
|  | predicates in xfail and none of the predicates in xpass. | 
|  |  | 
|  | Example: | 
|  | xfail: | 
|  | # Vulkan is currently structurally broken with dyndims. | 
|  | - matrix.backend: iree_vulkan | 
|  | matrix.dyndims: "true" | 
|  | xpass: | 
|  | # Some dynamic dim functions pass on vulkan regardless of overall | 
|  | # broken support. | 
|  | - matrix.backend: iree_vulkan | 
|  | matrix.function: *VULKAN_DYNDIM_PASS_MATH_FUNCTIONS | 
|  | matrix.dyndims: "true" | 
|  |  | 
|  | Note that each entry in xfail/xpass is a mapping of key/value pairs. Any keys | 
|  | that start with 'matrix.' are taken to match an expanded matrix placeholder. | 
|  | A mapping predicate is evaluated as a product-of-sums where the overall | 
|  | predicate is true if all of the listed keys have a match for any of their | 
|  | values. | 
|  |  | 
|  | TODO: Add support for skipping combinations. | 
|  | TODO: Add support for annotating combinations with 'requires' for env feature | 
|  | checking. | 
|  | """ | 
|  |  | 
|  | from typing import Set, Tuple | 
|  |  | 
|  | import argparse | 
|  | from contextlib import contextmanager | 
|  | import os | 
|  | import shutil | 
|  |  | 
|  | try: | 
|  | import yaml | 
|  | except ModuleNotFoundError as e: | 
|  | raise RuntimeError( | 
|  | f"PyYAML is not installed. Typically: 'python -m pip install PyYAML" | 
|  | ) from e | 
|  |  | 
|  | ################################################################################ | 
|  | # Base classes and types | 
|  | ################################################################################ | 
|  |  | 
|  |  | 
|  | class Environment: | 
|  | """Runtime environment for processing a directory.""" | 
|  |  | 
|  | def __init__(self, args, root_dir: str, output_dir: str): | 
|  | self.args = args | 
|  | self.root_dir = root_dir | 
|  | self.output_dir = output_dir | 
|  | # Set of directories containing purely generated files. | 
|  | self.gen_dirs = set()  # type: Set[str] | 
|  | # Set of (gen_dir, file_name) for all files in a given directory that have | 
|  | # been generated. | 
|  | self.gen_files = set()  # type: Set[Tuple[str, str]] | 
|  |  | 
|  | def remember_gen_file(self, gen_file_path: str): | 
|  | gen_dir = os.path.dirname(gen_file_path) | 
|  | gen_file = os.path.basename(gen_file_path) | 
|  | self.gen_dirs.add(gen_dir) | 
|  | self.gen_files.add((gen_dir, gen_file)) | 
|  |  | 
|  | def prune_gen_files(self): | 
|  | found_gen_files = set() | 
|  | for gen_dir in self.gen_dirs: | 
|  | dir_listing = os.listdir(gen_dir) | 
|  | for fname in dir_listing: | 
|  | found_gen_files.add((gen_dir, fname)) | 
|  | obsolete_gen_files = found_gen_files - self.gen_files | 
|  | if obsolete_gen_files: | 
|  | for gen_dir, fname in obsolete_gen_files: | 
|  | obsolete_path = os.path.join(gen_dir, fname) | 
|  | log(f"Removing obsolete file {obsolete_path}") | 
|  | if os.path.isdir(obsolete_path): | 
|  | shutil.rmtree(obsolete_path) | 
|  | else: | 
|  | os.remove(obsolete_path) | 
|  |  | 
|  |  | 
|  | class Runner: | 
|  | """Base class for a runner.""" | 
|  | RUNNER_IDENT = None | 
|  |  | 
|  | def __init__(self, env: Environment, test_id: str): | 
|  | self.env = env | 
|  | self.test_id = test_id | 
|  | self.gen_dir = os.path.join(self.env.output_dir, "generated") | 
|  | self.xfail = False | 
|  |  | 
|  | @property | 
|  | def runner_ident(self) -> str: | 
|  | assert self.RUNNER_IDENT, "Must define RUNNER_IDENT" | 
|  | return self.RUNNER_IDENT | 
|  |  | 
|  | def create_gen_file(self, file_name: str, mode: str = "wt"): | 
|  | os.makedirs(self.gen_dir, exist_ok=True) | 
|  | full_path = os.path.join(self.gen_dir, file_name) | 
|  | handle = open(full_path, mode) | 
|  | self.env.remember_gen_file(full_path) | 
|  | return handle | 
|  |  | 
|  | def link_file(self, from_path: str, to_path: str): | 
|  | if from_path == to_path: | 
|  | return | 
|  | from_path = os.path.realpath(from_path) | 
|  | os.makedirs(os.path.dirname(to_path), exist_ok=True) | 
|  | if os.path.exists(to_path): | 
|  | os.remove(to_path) | 
|  | os.symlink(from_path, to_path) | 
|  |  | 
|  | def generate(self): | 
|  | raise NotImplementedError(f"Generate not implemented for {self.__class__}") | 
|  |  | 
|  |  | 
|  | ################################################################################ | 
|  | # Main logic | 
|  | ################################################################################ | 
|  |  | 
|  |  | 
|  | def parse_arguments(): | 
|  | parser = argparse.ArgumentParser(description="Test matrix generator") | 
|  | parser.add_argument("--dir", | 
|  | required=True, | 
|  | type=str, | 
|  | help="Directory to process") | 
|  | parser.add_argument("--output_dir", | 
|  | required=True, | 
|  | type=str, | 
|  | help="Output directory") | 
|  | args = parser.parse_args() | 
|  | return args | 
|  |  | 
|  |  | 
|  | def main(args): | 
|  | env = Environment(args, args.dir, args.output_dir) | 
|  | process_directory(env) | 
|  |  | 
|  |  | 
|  | def process_directory(env: Environment): | 
|  | dir = os.path.realpath(env.root_dir) | 
|  | try: | 
|  | config_sections = read_directory_config(dir) | 
|  | except Exception as e: | 
|  | raise RuntimeError(f"Could not read configuration from {dir}") from e | 
|  | for section in config_sections: | 
|  | require_mapping(section) | 
|  | for config_key, config_value in section.items(): | 
|  | if config_key == "lists": | 
|  | # Ignore: a place to stash anchors and references. | 
|  | pass | 
|  | elif config_key == "test_groups": | 
|  | require_list(config_value) | 
|  | for test_group in config_value: | 
|  | require_mapping(test_group) | 
|  | process_test_group(env, test_group) | 
|  | else: | 
|  | raise ValueError(f"Unexpected top-level section {config_key}") | 
|  |  | 
|  | env.prune_gen_files() | 
|  |  | 
|  |  | 
|  | def process_test_group(env: Environment, test_group): | 
|  | group_id = get_mapping_key(test_group, "id", require_str) | 
|  | matrix = generate_matrix( | 
|  | get_mapping_key(test_group, "matrix", require_mapping)) | 
|  | matrix_id_map = {group_id.format(**m): m for m in matrix} | 
|  | for runner_map in get_mapping_key(test_group, "runner", require_list): | 
|  | for matrix_id, matrix_map in matrix_id_map.items(): | 
|  | runner = create_runner(env, matrix_id, runner_map, matrix_map) | 
|  | runner.xfail = (evaluate_xfail(test_group, matrix_map) and | 
|  | not evaluate_xpass(test_group, matrix_map)) | 
|  | runner.generate() | 
|  |  | 
|  |  | 
|  | def evaluate_xfail(test_group, matrix_map) -> bool: | 
|  | try: | 
|  | xfail_list = flatten_lists(require_list(test_group["xfail"])) | 
|  | except KeyError: | 
|  | return False | 
|  | for xfail_group in xfail_list: | 
|  | if evaluate_matrix_map_predicate(matrix_map, xfail_group): | 
|  | return True | 
|  | return False | 
|  |  | 
|  |  | 
|  | def evaluate_xpass(test_group, matrix_map) -> bool: | 
|  | try: | 
|  | xpass_list = flatten_lists(require_list(test_group["xpass"])) | 
|  | except KeyError: | 
|  | return False | 
|  | for xpass_group in xpass_list: | 
|  | if evaluate_matrix_map_predicate(matrix_map, xpass_group): | 
|  | return True | 
|  | return False | 
|  |  | 
|  |  | 
|  | def evaluate_matrix_map_predicate(matrix_map, predicate_group) -> bool: | 
|  | # Each key is something like 'matrix.<key>' which are and'ed | 
|  | # together. Each value is either a literal or a list that is | 
|  | # or'd together. | 
|  | for pred_key, pred_value in predicate_group.items(): | 
|  | match_value = None | 
|  | if pred_key.startswith("matrix."): | 
|  | try: | 
|  | match_value = matrix_map[pred_key[len("matrix."):]] | 
|  | except KeyError: | 
|  | raise ValueError( | 
|  | f"Could not match matrix predicate to matrix value: {pred_key}") | 
|  | else: | 
|  | raise ValueError( | 
|  | f"Expected a matrix predicate (i.e. matrix.) but got {pred_key}") | 
|  | # Match list (OR) or literal (==) | 
|  | if isinstance(pred_value, list): | 
|  | if match_value not in flatten_lists(pred_value): | 
|  | return False | 
|  | else: | 
|  | if pred_value != match_value: | 
|  | return False | 
|  | return True | 
|  |  | 
|  |  | 
|  | ################################################################################ | 
|  | # Utilities | 
|  | ################################################################################ | 
|  |  | 
|  |  | 
|  | def generate_matrix(matrix_map): | 
|  | # List of (key, [value, value, ...]) | 
|  | matrix_entries = [(k, flatten_lists(v)) for k, v in matrix_map.items()] | 
|  | # Permute. | 
|  | permuted = [] | 
|  |  | 
|  | def accumulate(prior: dict, i: int): | 
|  | if i == len(matrix_entries): | 
|  | permuted.append(prior) | 
|  | return | 
|  | next_key, next_values = matrix_entries[i] | 
|  | for next_value in next_values: | 
|  | current = dict(prior) | 
|  | current[next_key] = next_value | 
|  | accumulate(current, i + 1) | 
|  |  | 
|  | accumulate({}, 0) | 
|  | return permuted | 
|  |  | 
|  |  | 
|  | def read_directory_config(dir: str) -> list: | 
|  | sections = [] | 
|  | matrix_path = os.path.join(dir, "test_matrix.yaml") | 
|  | with open(matrix_path, "r") as stream: | 
|  | for section in yaml.safe_load_all(stream): | 
|  | sections.append(section) | 
|  | return sections | 
|  |  | 
|  |  | 
|  | INDENT = 0 | 
|  |  | 
|  |  | 
|  | def log(msg: str): | 
|  | print("  " * INDENT + msg) | 
|  |  | 
|  |  | 
|  | @contextmanager | 
|  | def indent(): | 
|  | global INDENT | 
|  | INDENT += 1 | 
|  | yield | 
|  | INDENT -= 1 | 
|  |  | 
|  |  | 
|  | def flatten_lists(l): | 
|  | results = list() | 
|  | for item in l: | 
|  | if isinstance(item, list): | 
|  | results.extend(flatten_lists(item)) | 
|  | else: | 
|  | results.append(item) | 
|  | return results | 
|  |  | 
|  |  | 
|  | def require_mapping(v): | 
|  | if isinstance(v, dict): | 
|  | return v | 
|  | raise ValueError(f"Expected a YAML mapping for {v}") | 
|  |  | 
|  |  | 
|  | def require_list(v): | 
|  | if isinstance(v, list): | 
|  | return v | 
|  | raise ValueError(f"Expected YAML list for {v}") | 
|  |  | 
|  |  | 
|  | def require_str(v): | 
|  | if isinstance(v, str): | 
|  | return v | 
|  | raise ValueError(f"Expected str for {v}") | 
|  |  | 
|  |  | 
|  | def get_mapping_key(mapping, key: str, checker=None): | 
|  | if key not in mapping: | 
|  | raise ValueError(f"Expected key '{key}' in {mapping}") | 
|  | value = mapping[key] | 
|  | if checker: | 
|  | checker(value) | 
|  | return value | 
|  |  | 
|  |  | 
|  | ################################################################################ | 
|  | # Runners | 
|  | ################################################################################ | 
|  |  | 
|  | PYRUNNER_STUB = r""" | 
|  | import importlib | 
|  | import os | 
|  | import sys | 
|  | resolved_imports = False | 
|  | for _ in range(2): | 
|  | try: | 
|  | for impname in REQUIRE_IMPORTS: | 
|  | importlib.import_module(impname) | 
|  | resolved_imports = True | 
|  | except ModuleNotFoundError as e: | 
|  | if os.path.exists(os.path.join(os.getcwd(), "CMakeCache.txt")): | 
|  | d = os.path.join(os.getcwd(), "bindings", "python") | 
|  | if os.path.exists(d): | 
|  | print(f"Added {d} to sys.path", file=sys.stderr) | 
|  | sys.path.append(d) | 
|  | if not resolved_imports: | 
|  | raise Exception(f"Cannot find required imports: {REQUIRE_IMPORTS}\n" | 
|  | f"If running interactively, ensure that you are in the " | 
|  | f"build directory or have packages installed") | 
|  |  | 
|  | sys.argv = [sys.argv[0]] + ARGS + sys.argv[1:] | 
|  | with open(MAIN, "rt") as f: | 
|  | script = f.read() | 
|  | FAILED = False | 
|  | try: | 
|  | exec(script, globals()) | 
|  | except SystemExit as exitex: | 
|  | # unittest like to sys.exit() itself. Catch that here so we can | 
|  | # process XFAIL properly. | 
|  | if exitex.code: FAILED = True | 
|  | except: | 
|  | FAILED = True | 
|  | import traceback | 
|  | traceback.print_exc() | 
|  | if not XFAIL: | 
|  | raise | 
|  |  | 
|  | if XFAIL: | 
|  | if FAILED: | 
|  | print("=== TEST FAILED AS EXPECTED ===", file=sys.stderr) | 
|  | sys.exit(0) | 
|  | else: | 
|  | print("=== TEST PASSED BUT WAS EXPECTED TO FAIL ===", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | if FAILED: | 
|  | sys.exit(1) | 
|  | """ | 
|  |  | 
|  |  | 
|  | class TfHostRunner(Runner): | 
|  | """Runner for tf e2e host tests.""" | 
|  | RUNNER_IDENT = "tfhost" | 
|  |  | 
|  | def __init__(self, env: Environment, test_id: str, runner_map: dict, | 
|  | matrix_map: dict): | 
|  | super().__init__(env=env, test_id=test_id) | 
|  | self.main_file = get_mapping_key(runner_map, "main", require_str) | 
|  | raw_arg_list = get_mapping_key(runner_map, "args", require_list) | 
|  | self.args = [ | 
|  | require_str(raw_arg).format(**matrix_map) for raw_arg in raw_arg_list | 
|  | ] | 
|  |  | 
|  | def generate(self): | 
|  | # Generate the runner script. | 
|  | file_name = ( | 
|  | f"{'XFAIL_' if self.xfail else ''}{self.test_id}_{self.runner_ident}.py" | 
|  | ) | 
|  | with self.create_gen_file(file_name) as f: | 
|  | parts = [ | 
|  | "import os", | 
|  | "import sys", | 
|  | "REQUIRE_IMPORTS = ['iree.tf.support.tf_utils', 'iree.tf.support.tf_test_utils']", | 
|  | f"ARGS = {repr(self.args)}", | 
|  | f"MAIN = os.path.join(os.path.dirname(__file__), '..', {repr(self.main_file)})", | 
|  | f"XFAIL = {self.xfail}", | 
|  | PYRUNNER_STUB, | 
|  | ] | 
|  | f.write("\n".join(parts)) | 
|  |  | 
|  | # Copy/link the main file. | 
|  | main_file_src_path = os.path.join(self.env.root_dir, self.main_file) | 
|  | main_file_dst_path = os.path.join(self.env.output_dir, self.main_file) | 
|  | if not os.path.exists(main_file_src_path): | 
|  | raise RuntimeError( | 
|  | f"Referenced main file '{main_file_src_path}' does not exist") | 
|  | self.link_file(main_file_src_path, main_file_dst_path) | 
|  |  | 
|  |  | 
|  | RUNNER_CLASSES = { | 
|  | "tfhost": TfHostRunner, | 
|  | } | 
|  |  | 
|  |  | 
|  | def create_runner(env: Environment, test_id: str, runner_map: dict, | 
|  | matrix_map: dict): | 
|  | runner_type = get_mapping_key(runner_map, "type", require_str) | 
|  | try: | 
|  | runner_class = RUNNER_CLASSES[runner_type] | 
|  | except KeyError: | 
|  | raise ValueError(f"Unknown runner type '{runner_type}'") | 
|  | return runner_class(env=env, | 
|  | test_id=test_id, | 
|  | runner_map=runner_map, | 
|  | matrix_map=matrix_map) | 
|  |  | 
|  |  | 
|  | if __name__ == "__main__": | 
|  | main(parse_arguments()) |