DXR is a code search and navigation tool aimed at making sense of large projects. It supports full-text and regex searches as well as structural queries.

Mercurial (f2644bf19c9f)

VCS Links

Line Code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
# 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/.

"""
A filter is a callable that accepts an iterable of test objects and a
dictionary of values, and returns a new iterable of test objects. It is
possible to define custom filters if the built-in ones are not enough.
"""

from __future__ import absolute_import

import itertools
import os
from abc import ABCMeta, abstractmethod
from collections import defaultdict, MutableSequence

from six import string_types

from .expression import (
    parse,
    ParseError,
)


# built-in filters

def skip_if(tests, values):
    """
    Sets disabled on all tests containing the `skip-if` tag and whose condition
    is True. This filter is added by default.
    """
    tag = 'skip-if'
    for test in tests:
        if tag in test and parse(test[tag], **values):
            test.setdefault('disabled', '{}: {}'.format(tag, test[tag]))
        yield test


def run_if(tests, values):
    """
    Sets disabled on all tests containing the `run-if` tag and whose condition
    is False. This filter is added by default.
    """
    tag = 'run-if'
    for test in tests:
        if tag in test and not parse(test[tag], **values):
            test.setdefault('disabled', '{}: {}'.format(tag, test[tag]))
        yield test


def fail_if(tests, values):
    """
    Sets expected to 'fail' on all tests containing the `fail-if` tag and whose
    condition is True. This filter is added by default.
    """
    tag = 'fail-if'
    for test in tests:
        if tag in test and parse(test[tag], **values):
            test['expected'] = 'fail'
        yield test


def enabled(tests, values):
    """
    Removes all tests containing the `disabled` key. This filter can be
    added by passing `disabled=False` into `active_tests`.
    """
    for test in tests:
        if 'disabled' not in test:
            yield test


def exists(tests, values):
    """
    Removes all tests that do not exist on the file system. This filter is
    added by default, but can be removed by passing `exists=False` into
    `active_tests`.
    """
    for test in tests:
        if os.path.exists(test['path']):
            yield test


# built-in instance filters

class InstanceFilter(object):
    """
    Generally only one instance of a class filter should be applied at a time.
    Two instances of `InstanceFilter` are considered equal if they have the
    same class name. This ensures only a single instance is ever added to
    `filterlist`. This class also formats filters' __str__ method for easier
    debugging.
    """
    unique = True

    def __init__(self, *args, **kwargs):
        self.fmt_args = ', '.join(itertools.chain(
            [str(a) for a in args],
            ['{}={}'.format(k, v) for k, v in kwargs.iteritems()]))

    def __eq__(self, other):
        if self.unique:
            return self.__class__ == other.__class__
        return self.__hash__() == other.__hash__()

    def __str__(self):
        return "{}({})".format(self.__class__.__name__, self.fmt_args)


class subsuite(InstanceFilter):
    """
    If `name` is None, removes all tests that have a `subsuite` key.
    Otherwise removes all tests that do not have a subsuite matching `name`.

    It is possible to specify conditional subsuite keys using:
       subsuite = foo,condition

    where 'foo' is the subsuite name, and 'condition' is the same type of
    condition used for skip-if.  If the condition doesn't evaluate to true,
    the subsuite designation will be removed from the test.

    :param name: The name of the subsuite to run (default None)
    """

    def __init__(self, name=None):
        InstanceFilter.__init__(self, name=name)
        self.name = name

    def __call__(self, tests, values):
        # Look for conditional subsuites, and replace them with the subsuite
        # itself (if the condition is true), or nothing.
        for test in tests:
            subsuite = test.get('subsuite', '')
            if ',' in subsuite:
                try:
                    subsuite, cond = subsuite.split(',')
                except ValueError:
                    raise ParseError("subsuite condition can't contain commas")
                matched = parse(cond, **values)
                if matched:
                    test['subsuite'] = subsuite
                else:
                    test['subsuite'] = ''

            # Filter on current subsuite
            if self.name is None:
                if not test.get('subsuite'):
                    yield test
            else:
                if test.get('subsuite', '') == self.name:
                    yield test


class chunk_by_slice(InstanceFilter):
    """
    Basic chunking algorithm that splits tests evenly across total chunks.

    :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks
    :param total_chunks: the total number of chunks
    :param disabled: Whether to include disabled tests in the chunking
                     algorithm. If False, each chunk contains an equal number
                     of non-disabled tests. If True, each chunk contains an
                     equal number of tests (default False)
    """

    def __init__(self, this_chunk, total_chunks, disabled=False):
        assert 1 <= this_chunk <= total_chunks
        InstanceFilter.__init__(self, this_chunk, total_chunks,
                                disabled=disabled)
        self.this_chunk = this_chunk
        self.total_chunks = total_chunks
        self.disabled = disabled

    def __call__(self, tests, values):
        tests = list(tests)
        if self.disabled:
            chunk_tests = tests[:]
        else:
            chunk_tests = [t for t in tests if 'disabled' not in t]

        tests_per_chunk = float(len(chunk_tests)) / self.total_chunks
        start = int(round((self.this_chunk - 1) * tests_per_chunk))
        end = int(round(self.this_chunk * tests_per_chunk))

        if not self.disabled:
            # map start and end back onto original list of tests. Disabled
            # tests will still be included in the returned list, but each
            # chunk will contain an equal number of enabled tests.
            if self.this_chunk == 1:
                start = 0
            elif start < len(chunk_tests):
                start = tests.index(chunk_tests[start])

            if self.this_chunk == self.total_chunks:
                end = len(tests)
            elif end < len(chunk_tests):
                end = tests.index(chunk_tests[end])
        return (t for t in tests[start:end])


class chunk_by_dir(InstanceFilter):
    """
    Basic chunking algorithm that splits directories of tests evenly at a
    given depth.

    For example, a depth of 2 means all test directories two path nodes away
    from the base are gathered, then split evenly across the total number of
    chunks. The number of tests in each of the directories is not taken into
    account (so chunks will not contain an even number of tests). All test
    paths must be relative to the same root (typically the root of the source
    repository).

    :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks
    :param total_chunks: the total number of chunks
    :param depth: the minimum depth of a subdirectory before it will be
                  considered unique
    """

    def __init__(self, this_chunk, total_chunks, depth):
        InstanceFilter.__init__(self, this_chunk, total_chunks, depth)
        self.this_chunk = this_chunk
        self.total_chunks = total_chunks
        self.depth = depth

    def __call__(self, tests, values):
        tests_by_dir = defaultdict(list)
        ordered_dirs = []
        for test in tests:
            path = test['relpath']

            if path.startswith(os.sep):
                path = path[1:]

            dirs = path.split(os.sep)
            dirs = dirs[:min(self.depth, len(dirs) - 1)]
            path = os.sep.join(dirs)

            # don't count directories that only have disabled tests in them,
            # but still yield disabled tests that are alongside enabled tests
            if path not in ordered_dirs and 'disabled' not in test:
                ordered_dirs.append(path)
            tests_by_dir[path].append(test)

        tests_per_chunk = float(len(ordered_dirs)) / self.total_chunks
        start = int(round((self.this_chunk - 1) * tests_per_chunk))
        end = int(round(self.this_chunk * tests_per_chunk))

        for i in range(start, end):
            for test in tests_by_dir.pop(ordered_dirs[i]):
                yield test

        # find directories that only contain disabled tests. They still need to
        # be yielded for reporting purposes. Put them all in chunk 1 for
        # simplicity.
        if self.this_chunk == 1:
            disabled_dirs = [v for k, v in tests_by_dir.iteritems()
                             if k not in ordered_dirs]
            for disabled_test in itertools.chain(*disabled_dirs):
                yield disabled_test


class ManifestChunk(InstanceFilter):
    """
    Base class for chunking tests by manifest using a numerical key.
    """
    __metaclass__ = ABCMeta

    def __init__(self, this_chunk, total_chunks, *args, **kwargs):
        InstanceFilter.__init__(self, this_chunk, total_chunks, *args, **kwargs)
        self.this_chunk = this_chunk
        self.total_chunks = total_chunks

    @abstractmethod
    def key(self, tests):
        pass

    def __call__(self, tests, values):
        tests = list(tests)
        manifests = set(t['manifest'] for t in tests)

        tests_by_manifest = []
        for manifest in manifests:
            mtests = [t for t in tests if t['manifest'] == manifest]
            tests_by_manifest.append((self.key(mtests), mtests))
        tests_by_manifest.sort(reverse=True)

        tests_by_chunk = [[0, []] for i in range(self.total_chunks)]
        for key, batch in tests_by_manifest:
            # sort first by key, then by number of tests in case of a tie.
            # This guarantees the chunk with the lowest key will always
            # get the next batch of tests.
            tests_by_chunk.sort(key=lambda x: (x[0], len(x[1])))
            tests_by_chunk[0][0] += key
            tests_by_chunk[0][1].extend(batch)

        return (t for t in tests_by_chunk[self.this_chunk - 1][1])


class chunk_by_manifest(ManifestChunk):
    """
    Chunking algorithm that tries to evenly distribute tests while ensuring
    tests in the same manifest stay together.

    :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks
    :param total_chunks: the total number of chunks
    """
    def key(self, tests):
        return len(tests)


class chunk_by_runtime(ManifestChunk):
    """
    Chunking algorithm that attempts to group tests into chunks based on their
    average runtimes. It keeps manifests of tests together and pairs slow
    running manifests with fast ones.

    :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks
    :param total_chunks: the total number of chunks
    :param runtimes: dictionary of test runtime data, of the form
                     {<test path>: <average runtime>}
    :param default_runtime: value in seconds to assign tests that don't exist
                            in the runtimes file
    """

    def __init__(self, this_chunk, total_chunks, runtimes, default_runtime=0):
        ManifestChunk.__init__(self, this_chunk, total_chunks, runtimes,
                               default_runtime=default_runtime)
        # defaultdict(lambda:<int>) assigns all non-existent keys the value of
        # <int>. This means all tests we encounter that don't exist in the
        # runtimes file will be assigned `default_runtime`.
        self.runtimes = defaultdict(lambda: default_runtime)
        self.runtimes.update(runtimes)

    def key(self, tests):
        return sum(self.runtimes[t['relpath']] for t in tests
                   if 'disabled' not in t)


class tags(InstanceFilter):
    """
    Removes tests that don't contain any of the given tags. This overrides
    InstanceFilter's __eq__ method, so multiple instances can be added.
    Multiple tag filters is equivalent to joining tags with the AND operator.

    To define a tag in a manifest, add a `tags` attribute to a test or DEFAULT
    section. Tests can have multiple tags, in which case they should be
    whitespace delimited. For example:

    [test_foobar.html]
    tags = foo bar

    :param tags: A tag or list of tags to filter tests on
    """
    unique = False

    def __init__(self, tags):
        InstanceFilter.__init__(self, tags)
        if isinstance(tags, string_types):
            tags = [tags]
        self.tags = tags

    def __call__(self, tests, values):
        for test in tests:
            if 'tags' not in test:
                continue

            test_tags = [t.strip() for t in test['tags'].split()]
            if any(t in self.tags for t in test_tags):
                yield test


class pathprefix(InstanceFilter):
    """
    Removes tests that don't start with any of the given test paths.

    :param paths: A list of test paths to filter on
    """

    def __init__(self, paths):
        InstanceFilter.__init__(self, paths)
        if isinstance(paths, string_types):
            paths = [paths]
        self.paths = paths

    def __call__(self, tests, values):
        for test in tests:
            for tp in self.paths:
                tp = os.path.normpath(tp)

                path = test['relpath']
                if os.path.isabs(tp):
                    path = test['path']

                if not os.path.normpath(path).startswith(tp):
                    continue

                # any test path that points to a single file will be run no
                # matter what, even if it's disabled
                if 'disabled' in test and os.path.normpath(test['relpath']) == tp:
                    del test['disabled']
                yield test
                break


# filter container

DEFAULT_FILTERS = (
    skip_if,
    run_if,
    fail_if,
)
"""
By default :func:`~.active_tests` will run the :func:`~.skip_if`,
:func:`~.run_if` and :func:`~.fail_if` filters.
"""


class filterlist(MutableSequence):
    """
    A MutableSequence that raises TypeError when adding a non-callable and
    ValueError if the item is already added.
    """

    def __init__(self, items=None):
        self.items = []
        if items:
            self.items = list(items)

    def _validate(self, item):
        if not callable(item):
            raise TypeError("Filters must be callable!")
        if item in self:
            raise ValueError("Filter {} is already applied!".format(item))

    def __getitem__(self, key):
        return self.items[key]

    def __setitem__(self, key, value):
        self._validate(value)
        self.items[key] = value

    def __delitem__(self, key):
        del self.items[key]

    def __len__(self):
        return len(self.items)

    def insert(self, index, value):
        self._validate(value)
        self.items.insert(index, value)