tools/tryselect/selectors/syntax.py
author Gregory Szorc <gps@mozilla.com>
Mon, 23 Oct 2017 10:36:38 -0700
changeset 685773 7506fda0a6e750808ee7ddfadf6eb4b4d92699bf
parent 681869 0300190ef9879f07055cc8d69cc6d5c9b41a4132
child 684915 6747fe8e9a87cb9cf9992c7972080a733471b9f1
permissions -rw-r--r--
Bug 1410969 - Return dict from find_paths_and_tags(); r=ahal This will make the return value more easily extensible and will help consumers know what the data structures represent. MozReview-Commit-ID: DaeYsqfMW37

# 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/.

from __future__ import absolute_import, print_function, unicode_literals

import os
import re
import sys
from collections import defaultdict

import mozpack.path as mozpath
from .. import preset
from ..cli import BaseTryParser
from ..vcs import VCSHelper


class SyntaxParser(BaseTryParser):
    name = 'syntax'
    arguments = [
        [['paths'],
         {'nargs': '*',
          'help': 'Paths to search for tests to run on try.',
          }],
        [['-b', '--build'],
         {'dest': 'builds',
          'default': 'do',
          'help': 'Build types to run (d for debug, o for optimized).',
          }],
        [['-p', '--platform'],
         {'dest': 'platforms',
          'action': 'append',
          'help': 'Platforms to run (required if not found in the environment as '
                  'AUTOTRY_PLATFORM_HINT).',
          }],
        [['-u', '--unittests'],
         {'dest': 'tests',
          'action': 'append',
          'help': 'Test suites to run in their entirety.',
          }],
        [['-t', '--talos'],
         {'action': 'append',
          'help': 'Talos suites to run.',
          }],
        [['-j', '--jobs'],
         {'action': 'append',
          'help': 'Job tasks to run.',
          }],
        [['--tag'],
         {'dest': 'tags',
          'action': 'append',
          'help': 'Restrict tests to the given tag (may be specified multiple times).',
          }],
        [['--and'],
         {'action': 'store_true',
          'dest': 'intersection',
          'help': 'When -u and paths are supplied run only the intersection of the '
                  'tests specified by the two arguments.',
          }],
        [['--no-artifact'],
         {'action': 'store_true',
          'help': 'Disable artifact builds even if --enable-artifact-builds is set '
                  'in the mozconfig.',
          }],
        [['-v', '--verbose'],
         {'dest': 'verbose',
          'action': 'store_true',
          'default': False,
          'help': 'Print detailed information about the resulting test selection '
                  'and commands performed.',
          }],
        [['--detect-paths'],
         {'dest': 'detect_paths',
          'action': 'store_true',
          'default': False,
          'help': 'Provide test paths based on files changed in the working copy.',
          }],
    ]

    # Arguments we will accept on the command line and pass through to try
    # syntax with no further intervention. The set is taken from
    # http://trychooser.pub.build.mozilla.org with a few additions.
    #
    # Note that the meaning of store_false and store_true arguments is
    # not preserved here, as we're only using these to echo the literal
    # arguments to another consumer. Specifying either store_false or
    # store_true here will have an equivalent effect.
    pass_through_arguments = {
        '--rebuild': {
            'action': 'store',
            'dest': 'rebuild',
            'help': 'Re-trigger all test jobs (up to 20 times)',
        },
        '--rebuild-talos': {
            'action': 'store',
            'dest': 'rebuild_talos',
            'help': 'Re-trigger all talos jobs',
        },
        '--interactive': {
            'action': 'store_true',
            'dest': 'interactive',
            'help': 'Allow ssh-like access to running test containers',
        },
        '--no-retry': {
            'action': 'store_true',
            'dest': 'no_retry',
            'help': 'Do not retrigger failed tests',
        },
        '--setenv': {
            'action': 'append',
            'dest': 'setenv',
            'help': 'Set the corresponding variable in the test environment for'
                    'applicable harnesses.',
        },
        '-f': {
            'action': 'store_true',
            'dest': 'failure_emails',
            'help': 'Request failure emails only',
        },
        '--failure-emails': {
            'action': 'store_true',
            'dest': 'failure_emails',
            'help': 'Request failure emails only',
        },
        '-e': {
            'action': 'store_true',
            'dest': 'all_emails',
            'help': 'Request all emails',
        },
        '--all-emails': {
            'action': 'store_true',
            'dest': 'all_emails',
            'help': 'Request all emails',
        },
        '--artifact': {
            'action': 'store_true',
            'dest': 'artifact',
            'help': 'Force artifact builds where possible.',
        },
        '--upload-xdbs': {
            'action': 'store_true',
            'dest': 'upload_xdbs',
            'help': 'Upload XDB compilation db files generated by hazard build',
        },
    }

    def __init__(self, *args, **kwargs):
        BaseTryParser.__init__(self, *args, **kwargs)

        group = self.add_argument_group("pass-through arguments")
        for arg, opts in self.pass_through_arguments.items():
            group.add_argument(arg, **opts)


class TryArgumentTokenizer(object):
    symbols = [("seperator", ","),
               ("list_start", "\["),
               ("list_end", "\]"),
               ("item", "([^,\[\]\s][^,\[\]]+)"),
               ("space", "\s+")]
    token_re = re.compile("|".join("(?P<%s>%s)" % item for item in symbols))

    def tokenize(self, data):
        for match in self.token_re.finditer(data):
            symbol = match.lastgroup
            data = match.group(symbol)
            if symbol == "space":
                pass
            else:
                yield symbol, data


class TryArgumentParser(object):
    """Simple three-state parser for handling expressions
    of the from "foo[sub item, another], bar,baz". This takes
    input from the TryArgumentTokenizer and runs through a small
    state machine, returning a dictionary of {top-level-item:[sub_items]}
    i.e. the above would result in
    {"foo":["sub item", "another"], "bar": [], "baz": []}
    In the case of invalid input a ValueError is raised."""

    EOF = object()

    def __init__(self):
        self.reset()

    def reset(self):
        self.tokens = None
        self.current_item = None
        self.data = {}
        self.token = None
        self.state = None

    def parse(self, tokens):
        self.reset()
        self.tokens = tokens
        self.consume()
        self.state = self.item_state
        while self.token[0] != self.EOF:
            self.state()
        return self.data

    def consume(self):
        try:
            self.token = self.tokens.next()
        except StopIteration:
            self.token = (self.EOF, None)

    def expect(self, *types):
        if self.token[0] not in types:
            raise ValueError("Error parsing try string, unexpected %s" % (self.token[0]))

    def item_state(self):
        self.expect("item")
        value = self.token[1].strip()
        if value not in self.data:
            self.data[value] = []
        self.current_item = value
        self.consume()
        if self.token[0] == "seperator":
            self.consume()
        elif self.token[0] == "list_start":
            self.consume()
            self.state = self.subitem_state
        elif self.token[0] == self.EOF:
            pass
        else:
            raise ValueError

    def subitem_state(self):
        self.expect("item")
        value = self.token[1].strip()
        self.data[self.current_item].append(value)
        self.consume()
        if self.token[0] == "seperator":
            self.consume()
        elif self.token[0] == "list_end":
            self.consume()
            self.state = self.after_list_end_state
        else:
            raise ValueError

    def after_list_end_state(self):
        self.expect("seperator")
        self.consume()
        self.state = self.item_state


def parse_arg(arg):
    tokenizer = TryArgumentTokenizer()
    parser = TryArgumentParser()
    return parser.parse(tokenizer.tokenize(arg))


class AutoTry(object):

    # Maps from flavors to the job names needed to run that flavour
    flavor_jobs = {
        'mochitest': ['mochitest-1', 'mochitest-e10s-1'],
        'xpcshell': ['xpcshell'],
        'chrome': ['mochitest-o'],
        'browser-chrome': ['mochitest-browser-chrome-1',
                           'mochitest-e10s-browser-chrome-1',
                           'mochitest-browser-chrome-e10s-1'],
        'devtools-chrome': ['mochitest-devtools-chrome-1',
                            'mochitest-e10s-devtools-chrome-1',
                            'mochitest-devtools-chrome-e10s-1'],
        'crashtest': ['crashtest', 'crashtest-e10s'],
        'reftest': ['reftest', 'reftest-e10s'],
        'web-platform-tests': ['web-platform-tests-1'],
    }

    flavor_suites = {
        "mochitest": "mochitests",
        "xpcshell": "xpcshell",
        "chrome": "mochitest-o",
        "browser-chrome": "mochitest-bc",
        "devtools-chrome": "mochitest-dt",
        "crashtest": "crashtest",
        "reftest": "reftest",
        "web-platform-tests": "web-platform-tests",
    }

    compiled_suites = [
        "cppunit",
        "gtest",
        "jittest",
    ]

    common_suites = [
        "cppunit",
        "crashtest",
        "firefox-ui-functional",
        "gtest",
        "jittest",
        "jsreftest",
        "marionette",
        "marionette-e10s",
        "mochitests",
        "reftest",
        "web-platform-tests",
        "xpcshell",
    ]

    def __init__(self, topsrcdir, resolver_func, mach_context):
        self.topsrcdir = topsrcdir
        self._resolver_func = resolver_func
        self._resolver = None
        self.mach_context = mach_context
        self.vcs = VCSHelper.create()

    @property
    def resolver(self):
        if self._resolver is None:
            self._resolver = self._resolver_func()
        return self._resolver

    def split_try_string(self, data):
        return re.findall(r'(?:\[.*?\]|\S)+', data)

    def paths_by_flavor(self, paths=None, tags=None):
        paths_by_flavor = defaultdict(set)

        if not (paths or tags):
            return dict(paths_by_flavor)

        tests = list(self.resolver.resolve_tests(paths=paths,
                                                 tags=tags))

        for t in tests:
            if t['flavor'] in self.flavor_suites:
                flavor = t['flavor']
                if 'subsuite' in t and t['subsuite'] == 'devtools':
                    flavor = 'devtools-chrome'

                if flavor in ['crashtest', 'reftest']:
                    manifest_relpath = os.path.relpath(t['manifest'], self.topsrcdir)
                    paths_by_flavor[flavor].add(os.path.dirname(manifest_relpath))
                elif 'dir_relpath' in t:
                    paths_by_flavor[flavor].add(t['dir_relpath'])
                else:
                    file_relpath = os.path.relpath(t['path'], self.topsrcdir)
                    dir_relpath = os.path.dirname(file_relpath)
                    paths_by_flavor[flavor].add(dir_relpath)

        for flavor, path_set in paths_by_flavor.items():
            paths_by_flavor[flavor] = self.deduplicate_prefixes(path_set, paths)

        return dict(paths_by_flavor)

    def deduplicate_prefixes(self, path_set, input_paths):
        # Removes paths redundant to test selection in the given path set.
        # If a path was passed on the commandline that is the prefix of a
        # path in our set, we only need to include the specified prefix to
        # run the intended tests (every test in "layout/base" will run if
        # "layout" is passed to the reftest harness).
        removals = set()
        additions = set()

        for path in path_set:
            full_path = path
            while path:
                path, _ = os.path.split(path)
                if path in input_paths:
                    removals.add(full_path)
                    additions.add(path)

        return additions | (path_set - removals)

    def remove_duplicates(self, paths_by_flavor, tests):
        rv = {}
        for item in paths_by_flavor:
            if self.flavor_suites[item] not in tests:
                rv[item] = paths_by_flavor[item].copy()
        return rv

    def calc_try_syntax(self, platforms, tests, talos, jobs, builds, paths_by_flavor, tags,
                        extras, intersection):
        parts = ["try:"]

        if platforms:
            parts.extend(["-b", builds, "-p", ",".join(platforms)])

        suites = tests if not intersection else {}
        paths = set()
        for flavor, flavor_tests in paths_by_flavor.iteritems():
            suite = self.flavor_suites[flavor]
            if suite not in suites and (not intersection or suite in tests):
                for job_name in self.flavor_jobs[flavor]:
                    for test in flavor_tests:
                        paths.add("%s:%s" % (flavor, test))
                    suites[job_name] = tests.get(suite, [])

        # intersection implies tests are expected
        if intersection and not suites:
            raise ValueError("No tests found matching filters")

        if extras.get('artifact') and any([p.endswith("-nightly") for p in platforms]):
            print('You asked for |--artifact| but "-nightly" platforms don\'t have artifacts. '
                  'Running without |--artifact| instead.')
            del extras['artifact']

        if extras.get('artifact'):
            rejected = []
            for suite in suites.keys():
                if any([suite.startswith(c) for c in self.compiled_suites]):
                    rejected.append(suite)
            if rejected:
                raise ValueError("You can't run {} with "
                                 "--artifact option.".format(', '.join(rejected)))

        if extras.get('artifact') and 'all' in suites.keys():
            non_compiled_suites = set(self.common_suites) - set(self.compiled_suites)
            message = ('You asked for |-u all| with |--artifact| but compiled-code tests ({tests})'
                       ' can\'t run against an artifact build. Running (-u {non_compiled_suites}) '
                       'instead.')
            string_format = {
                'tests': ','.join(self.compiled_suites),
                'non_compiled_suites': ','.join(non_compiled_suites),
            }
            print(message.format(**string_format))
            del suites['all']
            suites.update({suite_name: None for suite_name in non_compiled_suites})

        if suites:
            parts.append("-u")
            parts.append(",".join("%s%s" % (k, "[%s]" % ",".join(v) if v else "")
                                  for k, v in sorted(suites.items())))

        if talos:
            parts.append("-t")
            parts.append(",".join("%s%s" % (k, "[%s]" % ",".join(v) if v else "")
                                  for k, v in sorted(talos.items())))

        if jobs:
            parts.append("-j")
            parts.append(",".join(jobs))

        if tags:
            parts.append(' '.join('--tag %s' % t for t in tags))

        if paths:
            parts.append("--try-test-paths %s" % " ".join(sorted(paths)))

        args_by_dest = {v['dest']: k for k, v in SyntaxParser.pass_through_arguments.items()}
        for dest, value in extras.iteritems():
            assert dest in args_by_dest
            arg = args_by_dest[dest]
            action = SyntaxParser.pass_through_arguments[arg]['action']
            if action == 'store':
                parts.append(arg)
                parts.append(value)
            if action == 'append':
                for e in value:
                    parts.append(arg)
                    parts.append(e)
            if action in ('store_true', 'store_false'):
                parts.append(arg)

        return " ".join(parts)

    def find_paths_and_tags(self, verbose, detect_paths):
        paths, tags = set(), set()
        changed_files = self.vcs.files_changed
        if changed_files and detect_paths:
            if verbose:
                print("Pushing tests based on modifications to the "
                      "following files:\n\t%s" % "\n\t".join(changed_files))

            from mozbuild.frontend.reader import (
                BuildReader,
                EmptyConfig,
            )

            config = EmptyConfig(self.topsrcdir)
            reader = BuildReader(config)
            files_info = reader.files_info(changed_files)

            for path, info in files_info.items():
                paths |= info.test_files
                tags |= info.test_tags

            if verbose:
                if paths:
                    print("Pushing tests based on the following patterns:\n\t%s" %
                          "\n\t".join(paths))
                if tags:
                    print("Pushing tests based on the following tags:\n\t%s" %
                          "\n\t".join(tags))

        return {
            'paths': paths,
            'tags': tags,
        }

    def normalise_list(self, items, allow_subitems=False):
        rv = defaultdict(list)
        for item in items:
            parsed = parse_arg(item)
            for key, values in parsed.iteritems():
                rv[key].extend(values)

        if not allow_subitems:
            if not all(item == [] for item in rv.itervalues()):
                raise ValueError("Unexpected subitems in argument")
            return rv.keys()
        else:
            return rv

    def validate_args(self, **kwargs):
        tests_selected = kwargs["tests"] or kwargs["paths"] or kwargs["tags"]
        if kwargs["platforms"] is None and (kwargs["jobs"] is None or tests_selected):
            if 'AUTOTRY_PLATFORM_HINT' in os.environ:
                kwargs["platforms"] = [os.environ['AUTOTRY_PLATFORM_HINT']]
            elif tests_selected:
                print("Must specify platform when selecting tests.")
                sys.exit(1)
            else:
                print("Either platforms or jobs must be specified as an argument to autotry.")
                sys.exit(1)

        try:
            platforms = (self.normalise_list(kwargs["platforms"])
                         if kwargs["platforms"] else {})
        except ValueError as e:
            print("Error parsing -p argument:\n%s" % e.message)
            sys.exit(1)

        try:
            tests = (self.normalise_list(kwargs["tests"], allow_subitems=True)
                     if kwargs["tests"] else {})
        except ValueError as e:
            print("Error parsing -u argument (%s):\n%s" % (kwargs["tests"], e.message))
            sys.exit(1)

        try:
            talos = (self.normalise_list(kwargs["talos"], allow_subitems=True)
                     if kwargs["talos"] else [])
        except ValueError as e:
            print("Error parsing -t argument:\n%s" % e.message)
            sys.exit(1)

        try:
            jobs = (self.normalise_list(kwargs["jobs"]) if kwargs["jobs"] else {})
        except ValueError as e:
            print("Error parsing -j argument:\n%s" % e.message)
            sys.exit(1)

        paths = []
        for p in kwargs["paths"]:
            p = mozpath.normpath(os.path.abspath(p))
            if not (os.path.isdir(p) and p.startswith(self.topsrcdir)):
                print('Specified path "%s" is not a directory under the srcdir,'
                      ' unable to specify tests outside of the srcdir' % p)
                sys.exit(1)
            if len(p) <= len(self.topsrcdir):
                print('Specified path "%s" is at the top of the srcdir and would'
                      ' select all tests.' % p)
                sys.exit(1)
            paths.append(os.path.relpath(p, self.topsrcdir))

        try:
            tags = self.normalise_list(kwargs["tags"]) if kwargs["tags"] else []
        except ValueError as e:
            print("Error parsing --tags argument:\n%s" % e.message)
            sys.exit(1)

        extra_values = {k['dest'] for k in SyntaxParser.pass_through_arguments.values()}
        extra_args = {k: v for k, v in kwargs.items()
                      if k in extra_values and v}

        return kwargs["builds"], platforms, tests, talos, jobs, paths, tags, extra_args

    def run(self, **kwargs):
        if kwargs["list_presets"]:
            preset.list_presets(section='try')
            sys.exit()

        if kwargs["preset"]:
            value = preset.load(kwargs["preset"], section='try')[0]
            defaults = vars(SyntaxParser().parse_args(self.split_try_string(value)))

            if defaults is None:
                print("No saved configuration called %s found in autotry.ini" % kwargs["preset"],
                      file=sys.stderr)

            for key, value in kwargs.iteritems():
                if value in (None, []) and key in defaults:
                    kwargs[key] = defaults[key]

        if not any(kwargs[item] for item in ("paths", "tests", "tags")):
            res = self.find_paths_and_tags(kwargs['verbose'],
                                           kwargs['detect_paths'])
            kwargs['paths'] = res['paths']
            kwargs['tags'] = res['tags']

        builds, platforms, tests, talos, jobs, paths, tags, extra = self.validate_args(**kwargs)

        if paths or tags:
            paths = [os.path.relpath(os.path.normpath(os.path.abspath(item)), self.topsrcdir)
                     for item in paths]
            paths_by_flavor = self.paths_by_flavor(paths=paths, tags=tags)

            if not paths_by_flavor and not tests:
                print("No tests were found when attempting to resolve paths:\n\n\t%s" %
                      paths)
                sys.exit(1)

            if not kwargs["intersection"]:
                paths_by_flavor = self.remove_duplicates(paths_by_flavor, tests)
        else:
            paths_by_flavor = {}

        # No point in dealing with artifacts if we aren't running any builds
        local_artifact_build = False
        if platforms:
            local_artifact_build = kwargs.get('local_artifact_build', False)

            # Add --artifact if --enable-artifact-builds is set ...
            if local_artifact_build:
                extra["artifact"] = True
            # ... unless --no-artifact is explicitly given.
            if kwargs["no_artifact"]:
                if "artifact" in extra:
                    del extra["artifact"]

        try:
            msg = self.calc_try_syntax(platforms, tests, talos, jobs, builds,
                                       paths_by_flavor, tags, extra, kwargs["intersection"])
        except ValueError as e:
            print(e.message)
            sys.exit(1)

        if local_artifact_build and not kwargs["no_artifact"]:
            print('mozconfig has --enable-artifact-builds; including '
                  '--artifact flag in try syntax (use --no-artifact '
                  'to override)')

        if kwargs["verbose"] and paths_by_flavor:
            print('The following tests will be selected: ')
            for flavor, paths in paths_by_flavor.iteritems():
                print("%s: %s" % (flavor, ",".join(paths)))

        if kwargs["verbose"]:
            print('The following try syntax was calculated:\n%s' % msg)

        self.vcs.push_to_try('syntax', kwargs["message"].format(msg=msg), push=kwargs['push'],
                             closed_tree=kwargs["closed_tree"])

        if kwargs["save"]:
            assert msg.startswith("try: ")
            msg = msg[len("try: "):]
            preset.save('try', kwargs["save"], msg)