diff options
Diffstat (limited to 'tools/lint/perfdocs/framework_gatherers.py')
-rw-r--r-- | tools/lint/perfdocs/framework_gatherers.py | 571 |
1 files changed, 571 insertions, 0 deletions
diff --git a/tools/lint/perfdocs/framework_gatherers.py b/tools/lint/perfdocs/framework_gatherers.py new file mode 100644 index 0000000000..a1dbf8ba52 --- /dev/null +++ b/tools/lint/perfdocs/framework_gatherers.py @@ -0,0 +1,571 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +import json +import os +import pathlib +import re + +from gecko_taskgraph.util.attributes import match_run_on_projects +from manifestparser import TestManifest +from mozperftest.script import ScriptInfo + +from perfdocs.doc_helpers import TableBuilder +from perfdocs.logger import PerfDocLogger +from perfdocs.utils import read_yaml + +logger = PerfDocLogger() + +BRANCHES = [ + "mozilla-central", + "autoland", + "mozilla-release", + "mozilla-beta", +] + +""" +This file is for framework specific gatherers since manifests +might be parsed differently in each of them. The gatherers +must implement the FrameworkGatherer class. +""" + + +class FrameworkGatherer(object): + """ + Abstract class for framework gatherers. + """ + + def __init__(self, yaml_path, workspace_dir, taskgraph={}): + """ + Generic initialization for a framework gatherer. + """ + self.workspace_dir = workspace_dir + self._yaml_path = yaml_path + self._taskgraph = taskgraph + self._suite_list = {} + self._test_list = {} + self._descriptions = {} + self._manifest_path = "" + self._manifest = None + self.script_infos = {} + self._task_list = {} + self._task_match_pattern = re.compile(r"([\w\W]*/[pgo|opt]*)-([\w\W]*)") + + def get_task_match(self, task_name): + return re.search(self._task_match_pattern, task_name) + + def get_manifest_path(self): + """ + Returns the path to the manifest based on the + manifest entry in the frameworks YAML configuration + file. + + :return str: Path to the manifest. + """ + if self._manifest_path: + return self._manifest_path + + yaml_content = read_yaml(self._yaml_path) + self._manifest_path = pathlib.Path(self.workspace_dir, yaml_content["manifest"]) + return self._manifest_path + + def get_suite_list(self): + """ + Each framework gatherer must return a dictionary with + the following structure. Note that the test names must + be relative paths so that issues can be correctly issued + by the reviewbot. + + :return dict: A dictionary with the following structure: { + "suite_name": [ + 'testing/raptor/test1', + 'testing/raptor/test2' + ] + } + """ + raise NotImplementedError + + def _build_section_with_header(self, title, content, header_type=None): + """ + Adds a section to the documentation with the title as the type mentioned + and paragraph as content mentioned. + :param title: title of the section + :param content: content of section paragraph + :param header_type: type of the title heading + """ + heading_map = {"H2": "*", "H3": "=", "H4": "-", "H5": "^"} + return [title, heading_map.get(header_type, "^") * len(title), content, ""] + + +class RaptorGatherer(FrameworkGatherer): + """ + Gatherer for the Raptor framework. + """ + + def get_suite_list(self): + """ + Returns a dictionary containing a mapping from suites + to the tests they contain. + + :return dict: A dictionary with the following structure: { + "suite_name": [ + 'testing/raptor/test1', + 'testing/raptor/test2' + ] + } + """ + if self._suite_list: + return self._suite_list + + manifest_path = self.get_manifest_path() + + # Get the tests from the manifest + test_manifest = TestManifest([str(manifest_path)], strict=False) + test_list = test_manifest.active_tests(exists=False, disabled=False) + + # Parse the tests into the expected dictionary + for test in test_list: + # Get the top-level suite + s = os.path.basename(test["here"]) + if s not in self._suite_list: + self._suite_list[s] = [] + + # Get the individual test + fpath = re.sub(".*testing", "testing", test["manifest"]) + + if fpath not in self._suite_list[s]: + self._suite_list[s].append(fpath) + + return self._suite_list + + def _get_ci_tasks(self): + for task in self._taskgraph.keys(): + if type(self._taskgraph[task]) == dict: + command = self._taskgraph[task]["task"]["payload"].get("command", []) + run_on_projects = self._taskgraph[task]["attributes"]["run_on_projects"] + else: + command = self._taskgraph[task].task["payload"].get("command", []) + run_on_projects = self._taskgraph[task].attributes["run_on_projects"] + + test_match = re.search(r"[\s']--test[\s=](.+?)[\s']", str(command)) + task_match = self.get_task_match(task) + if test_match and task_match: + test = test_match.group(1) + platform = task_match.group(1) + test_name = task_match.group(2) + + item = {"test_name": test_name, "run_on_projects": run_on_projects} + self._task_list.setdefault(test, {}).setdefault(platform, []).append( + item + ) + + def _get_subtests_from_ini(self, manifest_path, suite_name): + """ + Returns a list of (sub)tests from an ini file containing the test definitions. + + :param str manifest_path: path to the ini file + :return list: the list of the tests + """ + desc_exclusion = ["here", "manifest_relpath", "path", "relpath"] + test_manifest = TestManifest([str(manifest_path)], strict=False) + test_list = test_manifest.active_tests(exists=False, disabled=False) + subtests = {} + for subtest in test_list: + subtests[subtest["name"]] = subtest["manifest"] + + description = {} + for key, value in subtest.items(): + if key not in desc_exclusion: + description[key] = value + + # Prepare alerting metrics for verification + description["metrics"] = [ + metric.strip() + for metric in description.get("alert_on", "").split(",") + if metric.strip() != "" + ] + + subtests[subtest["name"]] = description + self._descriptions.setdefault(suite_name, []).append(description) + + self._descriptions[suite_name].sort(key=lambda item: item["name"]) + + return subtests + + def get_test_list(self): + """ + Returns a dictionary containing the tests in every suite ini file. + + :return dict: A dictionary with the following structure: { + "suite_name": { + 'raptor_test1', + 'raptor_test2' + }, + } + """ + if self._test_list: + return self._test_list + + suite_list = self.get_suite_list() + + # Iterate over each manifest path from suite_list[suite_name] + # and place the subtests into self._test_list under the same key + for suite_name, manifest_paths in suite_list.items(): + if not self._test_list.get(suite_name): + self._test_list[suite_name] = {} + for manifest_path in manifest_paths: + subtest_list = self._get_subtests_from_ini(manifest_path, suite_name) + self._test_list[suite_name].update(subtest_list) + + self._get_ci_tasks() + + return self._test_list + + def build_test_description(self, title, test_description="", suite_name=""): + matcher = [] + browsers = [ + "firefox", + "chrome", + "chromium", + "refbrow", + "fennec68", + "geckoview", + "fenix", + ] + test_name = [f"{title}-{browser}" for browser in browsers] + test_name.append(title) + + for suite, val in self._descriptions.items(): + for test in val: + if test["name"] in test_name and suite_name == suite: + matcher.append(test) + + if len(matcher) == 0: + logger.critical( + "No tests exist for the following name " + "(obtained from config.yml): {}".format(title) + ) + raise Exception( + "No tests exist for the following name " + "(obtained from config.yml): {}".format(title) + ) + + result = f".. dropdown:: {title}\n" + result += f" :class-container: anchor-id-{title}-{suite_name[0]}\n\n" + + for idx, description in enumerate(matcher): + if description["name"] != title: + result += f" {idx+1}. **{description['name']}**\n\n" + if "owner" in description.keys(): + result += f" **Owner**: {description['owner']}\n\n" + + for key in sorted(description.keys()): + if key in ["owner", "name", "manifest", "metrics"]: + continue + sub_title = key.replace("_", " ") + if key == "test_url": + if "<" in description[key] or ">" in description[key]: + description[key] = description[key].replace("<", "\<") + description[key] = description[key].replace(">", "\>") + result += f" * **{sub_title}**: `<{description[key]}>`__\n" + elif key == "secondary_url": + result += f" * **{sub_title}**: `<{description[key]}>`__\n" + elif key in ["playback_pageset_manifest"]: + result += ( + f" * **{sub_title}**: " + f"{description[key].replace('{subtest}', description['name'])}\n" + ) + else: + if "\n" in description[key]: + description[key] = description[key].replace("\n", " ") + result += f" * **{sub_title}**: {description[key]}\n" + + if self._task_list.get(title, []): + result += " * **Test Task**:\n\n" + for platform in sorted(self._task_list[title]): + self._task_list[title][platform].sort(key=lambda x: x["test_name"]) + + table = TableBuilder( + title=platform, + widths=[30] + [15 for x in BRANCHES], + header_rows=1, + headers=[["Test Name"] + BRANCHES], + indent=3, + ) + + for task in self._task_list[title][platform]: + values = [task["test_name"]] + values += [ + "\u2705" + if match_run_on_projects(x, task["run_on_projects"]) + else "\u274C" + for x in BRANCHES + ] + table.add_row(values) + result += f"{table.finish_table()}\n" + + return [result] + + def build_suite_section(self, title, content): + return self._build_section_with_header( + title.capitalize(), content, header_type="H4" + ) + + +class MozperftestGatherer(FrameworkGatherer): + """ + Gatherer for the Mozperftest framework. + """ + + def get_test_list(self): + """ + Returns a dictionary containing the tests that are in perftest.ini manifest. + + :return dict: A dictionary with the following structure: { + "suite_name": { + 'perftest_test1', + 'perftest_test2', + }, + } + """ + for path in pathlib.Path(self.workspace_dir).rglob("perftest.ini"): + if "obj-" in str(path): + continue + suite_name = str(path.parent).replace(str(self.workspace_dir), "") + + # If the workspace dir doesn't end with a forward-slash, + # the substitution above won't work completely + if suite_name.startswith("/") or suite_name.startswith("\\"): + suite_name = suite_name[1:] + + # We have to add new paths to the logger as we search + # because mozperftest tests exist in multiple places in-tree + PerfDocLogger.PATHS.append(suite_name) + + # Get the tests from perftest.ini + test_manifest = TestManifest([str(path)], strict=False) + test_list = test_manifest.active_tests(exists=False, disabled=False) + for test in test_list: + si = ScriptInfo(test["path"]) + self.script_infos[si["name"]] = si + self._test_list.setdefault(suite_name.replace("\\", "/"), {}).update( + {si["name"]: {"path": str(path)}} + ) + + return self._test_list + + def build_test_description(self, title, test_description="", suite_name=""): + return [str(self.script_infos[title])] + + def build_suite_section(self, title, content): + return self._build_section_with_header(title, content, header_type="H4") + + +class TalosGatherer(FrameworkGatherer): + def _get_ci_tasks(self): + with open( + pathlib.Path(self.workspace_dir, "testing", "talos", "talos.json") + ) as f: + config_suites = json.load(f)["suites"] + + for task_name in self._taskgraph.keys(): + task = self._taskgraph[task_name] + + if type(task) == dict: + is_talos = task["task"]["extra"].get("suite", []) + command = task["task"]["payload"].get("command", []) + run_on_projects = task["attributes"]["run_on_projects"] + else: + is_talos = task.task["extra"].get("suite", []) + command = task.task["payload"].get("command", []) + run_on_projects = task.attributes["run_on_projects"] + + suite_match = re.search(r"[\s']--suite[\s=](.+?)[\s']", str(command)) + task_match = self.get_task_match(task_name) + if "talos" == is_talos and task_match: + suite = suite_match.group(1) + platform = task_match.group(1) + test_name = task_match.group(2) + item = {"test_name": test_name, "run_on_projects": run_on_projects} + + for test in config_suites[suite]["tests"]: + self._task_list.setdefault(test, {}).setdefault( + platform, [] + ).append(item) + + def get_test_list(self): + from talos import test as talos_test + + test_lists = talos_test.test_dict() + mod = __import__("talos.test", fromlist=test_lists) + + suite_name = "Talos Tests" + + for test in test_lists: + self._test_list.setdefault(suite_name, {}).update({test: {}}) + + klass = getattr(mod, test) + self._descriptions.setdefault(test, klass.__dict__) + + self._get_ci_tasks() + + return self._test_list + + def build_test_description(self, title, test_description="", suite_name=""): + result = f".. dropdown:: {title}\n" + result += f" :class-container: anchor-id-{title}\n\n" + + yml_descriptions = [s.strip() for s in test_description.split("- ") if s] + for description in yml_descriptions: + if "Example Data" in description: + # Example Data for using code block + example_list = [s.strip() for s in description.split("* ")] + result += f" * {example_list[0]}\n" + result += "\n .. code-block::\n\n" + for example in example_list[1:]: + result += f" {example}\n" + result += "\n" + + elif " * " in description: + # Sub List + sub_list = [s.strip() for s in description.split(" * ")] + result += f" * {sub_list[0]}\n" + for sub in sub_list[1:]: + result += f" * {sub}\n" + + else: + # General List + result += f" * {description}\n" + + if title in self._descriptions: + for key in sorted(self._descriptions[title]): + if key.startswith("__") and key.endswith("__"): + continue + elif key == "filters": + continue + + # On windows, we get the paths in the wrong style + value = self._descriptions[title][key] + if isinstance(value, dict): + for k, v in value.items(): + if isinstance(v, str) and "\\" in v: + value[k] = str(v).replace("\\", r"/") + result += r" * " + key + r": " + str(value) + r"\n" + + # Command + result += " * Command\n\n" + result += " .. code-block::\n\n" + result += f" ./mach talos-test -a {title}\n\n" + + if self._task_list.get(title, []): + result += " * **Test Task**:\n\n" + for platform in sorted(self._task_list[title]): + self._task_list[title][platform].sort(key=lambda x: x["test_name"]) + + table = TableBuilder( + title=platform, + widths=[30] + [15 for x in BRANCHES], + header_rows=1, + headers=[["Test Name"] + BRANCHES], + indent=3, + ) + + for task in self._task_list[title][platform]: + values = [task["test_name"]] + values += [ + "\u2705" + if match_run_on_projects(x, task["run_on_projects"]) + else "\u274C" + for x in BRANCHES + ] + table.add_row(values) + result += f"{table.finish_table()}\n" + + return [result] + + def build_suite_section(self, title, content): + return self._build_section_with_header(title, content, header_type="H2") + + +class AwsyGatherer(FrameworkGatherer): + """ + Gatherer for the Awsy framework. + """ + + def _generate_ci_tasks(self): + for task_name in self._taskgraph.keys(): + task = self._taskgraph[task_name] + + if type(task) == dict: + awsy_test = task["task"]["extra"].get("suite", []) + run_on_projects = task["attributes"]["run_on_projects"] + else: + awsy_test = task.task["extra"].get("suite", []) + run_on_projects = task.attributes["run_on_projects"] + + task_match = self.get_task_match(task_name) + + if "awsy" in awsy_test and task_match: + platform = task_match.group(1) + test_name = task_match.group(2) + item = {"test_name": test_name, "run_on_projects": run_on_projects} + self._task_list.setdefault(platform, []).append(item) + + def get_suite_list(self): + self._suite_list = {"Awsy tests": ["tp6", "base", "dmd", "tp5"]} + return self._suite_list + + def get_test_list(self): + self._generate_ci_tasks() + return { + "Awsy tests": { + "tp6": {}, + "base": {}, + "dmd": {}, + "tp5": {}, + } + } + + def build_suite_section(self, title, content): + return self._build_section_with_header( + title.capitalize(), content, header_type="H4" + ) + + def build_test_description(self, title, test_description="", suite_name=""): + dropdown_suite_name = suite_name.replace(" ", "-") + result = f".. dropdown:: {title} ({test_description})\n" + result += f" :class-container: anchor-id-{title}-{dropdown_suite_name}\n\n" + + awsy_data = read_yaml(self._yaml_path)["suites"]["Awsy tests"] + if "owner" in awsy_data.keys(): + result += f" **Owner**: {awsy_data['owner']}\n\n" + result += " * **Test Task**:\n" + + # tp5 tests are represented by awsy-e10s test names + # while the others have their title in test names + search_tag = "awsy-e10s" if title == "tp5" else title + for platform in sorted(self._task_list.keys()): + result += f" * {platform}\n" + for test_dict in sorted( + self._task_list[platform], key=lambda d: d["test_name"] + ): + if search_tag in test_dict["test_name"]: + run_on_project = ": " + ( + ", ".join(test_dict["run_on_projects"]) + if test_dict["run_on_projects"] + else "None" + ) + result += ( + f" * {test_dict['test_name']}{run_on_project}\n" + ) + result += "\n" + + return [result] + + +class StaticGatherer(FrameworkGatherer): + """ + A noop gatherer for frameworks with static-only documentation. + """ + + pass |