Bug 1230962 - Add python/mozlint for running several linters at once, r?smacleod draft
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Wed, 16 Mar 2016 14:55:21 -0400
changeset 363471 15b03d0b81fe7142fcc81c9e7f91a513a7cce721
parent 363286 369a5ee3a2880a4a98df3a00bf3db8d8f36b181b
child 363472 29b3b9e3d6e139d089d04c69c690d535d15ddace
push id17212
push userahalberstadt@mozilla.com
push dateWed, 04 May 2016 19:57:22 +0000
reviewerssmacleod
bugs1230962
milestone49.0a1
Bug 1230962 - Add python/mozlint for running several linters at once, r?smacleod Mozlint provides two main benefits: 1. A common system for defining lints across multiple languages 2. A common interface and result format for running them This commit only adds the core library, it does not add any consumers of mozlint just yet. MozReview-Commit-ID: CSQzq5del5k
python/mozlint/mozlint/__init__.py
python/mozlint/mozlint/errors.py
python/mozlint/mozlint/formatters/__init__.py
python/mozlint/mozlint/formatters/stylish.py
python/mozlint/mozlint/parser.py
python/mozlint/mozlint/result.py
python/mozlint/mozlint/roller.py
python/mozlint/mozlint/types.py
python/mozlint/setup.py
python/mozlint/test/__init__.py
python/mozlint/test/files/foobar.js
python/mozlint/test/files/no_foobar.js
python/mozlint/test/linters/external.lint
python/mozlint/test/linters/invalid_exclude.lint
python/mozlint/test/linters/invalid_extension.lnt
python/mozlint/test/linters/invalid_include.lint
python/mozlint/test/linters/invalid_type.lint
python/mozlint/test/linters/missing_attrs.lint
python/mozlint/test/linters/missing_definition.lint
python/mozlint/test/linters/raises.lint
python/mozlint/test/linters/regex.lint
python/mozlint/test/linters/string.lint
python/mozlint/test/test_formatters.py
python/mozlint/test/test_parser.py
python/mozlint/test/test_roller.py
python/mozlint/test/test_types.py
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/__init__.py
@@ -0,0 +1,7 @@
+# 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/.
+# flake8: noqa
+
+from .roller import LintRoller
+from .result import ResultContainer
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/errors.py
@@ -0,0 +1,25 @@
+# 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 os
+
+
+class LintException(Exception):
+    pass
+
+
+class LinterNotFound(LintException):
+    def __init__(self, path):
+        LintException.__init__(self, "Could not find lint file '{}'".format(path))
+
+
+class LinterParseError(LintException):
+    def __init__(self, path, message):
+        LintException.__init__(self, "{}: {}".format(os.path.basename(path), message))
+
+
+class LintersNotConfigured(LintException):
+    def __init__(self):
+        LintException.__init__(self, "No linters registered! Use `LintRoller.read` "
+                                     "to register a linter.")
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/formatters/__init__.py
@@ -0,0 +1,23 @@
+# 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
+
+from ..result import ResultEncoder
+from .stylish import StylishFormatter
+
+
+class JSONFormatter(object):
+    def __call__(self, results):
+        return json.dumps(results, cls=ResultEncoder)
+
+
+all_formatters = {
+    'json': JSONFormatter,
+    'stylish': StylishFormatter,
+}
+
+
+def get(name, **fmtargs):
+    return all_formatters[name](**fmtargs)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/formatters/stylish.py
@@ -0,0 +1,104 @@
+# 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 unicode_literals
+
+from ..result import ResultContainer
+
+try:
+    import blessings
+except ImportError:
+    blessings = None
+
+
+class NullTerminal(object):
+    """Replacement for `blessings.Terminal()` that does no formatting."""
+    class NullCallableString(unicode):
+        """A dummy callable Unicode stolen from blessings"""
+        def __new__(cls):
+            new = unicode.__new__(cls, u'')
+            return new
+
+        def __call__(self, *args):
+            if len(args) != 1 or isinstance(args[0], int):
+                return u''
+            return args[0]
+
+    def __getattr__(self, attr):
+        return self.NullCallableString()
+
+
+class StylishFormatter(object):
+    """Formatter based on the eslint default."""
+
+    fmt = "  {c1}{lineno}:{column}  {c2}{level}{normal}  {message}  {c1}{rule}({linter}){normal}"
+    fmt_summary = "{t.bold}{c}\u2716 {problem} ({error}, {warning}){t.normal}"
+
+    def __init__(self, disable_colors=None):
+        if disable_colors or not blessings:
+            self.term = NullTerminal()
+        else:
+            self.term = blessings.Terminal()
+
+    def _reset_max(self):
+        self.max_lineno = 0
+        self.max_column = 0
+        self.max_level = 0
+        self.max_message = 0
+
+    def _update_max(self, err):
+        """Calculates the longest length of each token for spacing."""
+        self.max_lineno = max(self.max_lineno, len(str(err.lineno)))
+        self.max_column = max(self.max_column, len(str(err.column)))
+        self.max_level = max(self.max_level, len(str(err.level)))
+        self.max_message = max(self.max_message, len(err.message))
+
+    def _pluralize(self, s, num):
+        if num != 1:
+            s += 's'
+        return str(num) + ' ' + s
+
+    def __call__(self, result):
+        message = []
+
+        num_errors = 0
+        num_warnings = 0
+        for path, errors in sorted(result.iteritems()):
+            self._reset_max()
+
+            message.append(self.term.underline(path))
+            # Do a first pass to calculate required padding
+            for err in errors:
+                assert isinstance(err, ResultContainer)
+                self._update_max(err)
+                if err.level == 'error':
+                    num_errors += 1
+                else:
+                    num_warnings += 1
+
+            for err in errors:
+                message.append(self.fmt.format(
+                    normal=self.term.normal,
+                    c1=self.term.color(8),
+                    c2=self.term.color(1) if err.level == 'error' else self.term.color(3),
+                    lineno=str(err.lineno).rjust(self.max_lineno),
+                    column=str(err.column).ljust(self.max_column),
+                    level=err.level.ljust(self.max_level),
+                    message=err.message.ljust(self.max_message),
+                    rule='{} '.format(err.rule) if err.rule else '',
+                    linter=err.linter.lower(),
+                ))
+
+            message.append('')  # newline
+
+        # Print a summary
+        message.append(self.fmt_summary.format(
+            t=self.term,
+            c=self.term.color(9) if num_errors else self.term.color(11),
+            problem=self._pluralize('problem', num_errors + num_warnings),
+            error=self._pluralize('error', num_errors),
+            warning=self._pluralize('warning', num_warnings),
+        ))
+
+        return '\n'.join(message)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/parser.py
@@ -0,0 +1,85 @@
+# 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 imp
+import os
+import sys
+import uuid
+
+from .types import supported_types
+from .errors import LinterNotFound, LinterParseError
+
+
+class Parser(object):
+    """Reads and validates `.lint` files."""
+    required_attributes = (
+        'name',
+        'description',
+        'type',
+        'payload',
+    )
+
+    def __call__(self, path):
+        return self.parse(path)
+
+    def _load_linter(self, path):
+        # Ensure parent module is present otherwise we'll (likely) get
+        # an error due to unknown parent.
+        parent_module = 'mozlint.linters'
+        if parent_module not in sys.modules:
+            mod = imp.new_module(parent_module)
+            sys.modules[parent_module] = mod
+
+        write_bytecode = sys.dont_write_bytecode
+        sys.dont_write_bytecode = True
+
+        module_name = '{}.{}'.format(parent_module, uuid.uuid1().get_hex())
+        imp.load_source(module_name, path)
+
+        sys.dont_write_bytecode = write_bytecode
+
+        mod = sys.modules[module_name]
+
+        if not hasattr(mod, 'LINTER'):
+            raise LinterParseError(path, "No LINTER definition found!")
+
+        definition = mod.LINTER
+        definition['path'] = path
+        return definition
+
+    def _validate(self, linter):
+        missing_attrs = []
+        for attr in self.required_attributes:
+            if attr not in linter:
+                missing_attrs.append(attr)
+
+        if missing_attrs:
+            raise LinterParseError(linter['path'], "Missing required attribute(s): "
+                                                   "{}".format(','.join(missing_attrs)))
+
+        if linter['type'] not in supported_types:
+            raise LinterParseError(linter['path'], "Invalid type '{}'".format(linter['type']))
+
+        for attr in ('include', 'exclude'):
+            if attr in linter and (not isinstance(linter[attr], list) or
+                                   not all(isinstance(a, basestring) for a in linter[attr])):
+                raise LinterParseError(linter['path'], "The {} directive must be a "
+                                                       "list of strings!".format(attr))
+
+    def parse(self, path):
+        """Read a linter and return its LINTER definition.
+
+        :param path: Path to the linter.
+        :returns: Linter definition (dict)
+        :raises: LinterNotFound, LinterParseError
+        """
+        if not os.path.isfile(path):
+            raise LinterNotFound(path)
+
+        if not path.endswith('.lint'):
+            raise LinterParseError(path, "Invalid filename, linters must end with '.lint'!")
+
+        linter = self._load_linter(path)
+        self._validate(linter)
+        return linter
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/result.py
@@ -0,0 +1,88 @@
+# 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 json import dumps, JSONEncoder
+
+
+class ResultContainer(object):
+    """Represents a single lint error and its related metadata.
+
+    :param linter: name of the linter that flagged this error
+    :param path: path to the file containing the error
+    :param message: text describing the error
+    :param lineno: line number that contains the error
+    :param column: column containing the error (default 1)
+    :param level: severity of the error, either 'warning' or 'error' (default 'error')
+    :param hint: suggestion for fixing the error (optional)
+    :param source: source code context of the error (optional)
+    :param rule: name of the rule that was violated (optional)
+    :param lineoffset: denotes an error spans multiple lines, of the form
+                       (<lineno offset>, <num lines>) (optional)
+    """
+
+    __slots__ = (
+        'linter',
+        'path',
+        'message',
+        'lineno',
+        'column',
+        'hint',
+        'source',
+        'level',
+        'rule',
+        'lineoffset',
+    )
+
+    def __init__(self, linter, path, message, lineno, column=1, hint=None,
+                 source=None, level='error', rule=None, lineoffset=None):
+        self.path = path
+        self.message = message
+        self.lineno = lineno
+        self.column = column
+        self.hint = hint
+        self.source = source
+        self.level = level
+        self.linter = linter
+        self.rule = rule
+        self.lineoffset = lineoffset
+
+    def __repr__(self):
+        s = dumps(self, cls=ResultEncoder, indent=2)
+        return "ResultContainer({})".format(s)
+
+
+class ResultEncoder(JSONEncoder):
+    """Class for encoding :class:`~result.ResultContainer`s to json.
+
+    Usage:
+
+        json.dumps(results, cls=ResultEncoder)
+    """
+    def default(self, o):
+        if isinstance(o, ResultContainer):
+            return {a: getattr(o, a) for a in o.__slots__}
+        return JSONEncoder.default(self, o)
+
+
+def from_linter(lintobj, **kwargs):
+    """Create a :class:`~result.ResultContainer` from a LINTER definition.
+
+    Convenience method that pulls defaults from a LINTER
+    definition and forwards them.
+
+    :param lintobj: LINTER obj as defined in a .lint file
+    :param kwargs: same as :class:`~result.ResultContainer`
+    :returns: :class:`~result.ResultContainer` object
+    """
+    attrs = {}
+    for attr in ResultContainer.__slots__:
+        attrs[attr] = kwargs.get(attr, lintobj.get(attr))
+
+    if not attrs['linter']:
+        attrs['linter'] = lintobj.get('name')
+
+    if not attrs['message']:
+        attrs['message'] = lintobj.get('description')
+
+    return ResultContainer(**attrs)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/roller.py
@@ -0,0 +1,111 @@
+# 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 signal
+import traceback
+from collections import defaultdict
+from Queue import Empty
+from multiprocessing import (
+    Manager,
+    Pool,
+    cpu_count,
+)
+
+from .errors import LintersNotConfigured
+from .types import supported_types
+from .parser import Parser
+
+
+def _run_linters(queue, paths, **lintargs):
+    parse = Parser()
+    results = defaultdict(list)
+
+    while True:
+        try:
+            linter_path = queue.get(False)
+        except Empty:
+            return results
+
+        # Ideally we would pass the entire LINTER definition as an argument
+        # to the worker instead of re-parsing it. But passing a function from
+        # a dynamically created module (with imp) does not seem to be possible
+        # with multiprocessing on Windows.
+        linter = parse(linter_path)
+        func = supported_types[linter['type']]
+        res = func(paths, linter, **lintargs) or []
+
+        for r in res:
+            results[r.path].append(r)
+
+
+def _run_worker(*args, **lintargs):
+    try:
+        return _run_linters(*args, **lintargs)
+    except:
+        traceback.print_exc()
+        raise
+
+
+class LintRoller(object):
+    """Registers and runs linters.
+
+    :param lintargs: Arguments to pass to the underlying linter(s).
+    """
+
+    def __init__(self, **lintargs):
+        self.parse = Parser()
+        self.linters = []
+        self.lintargs = lintargs
+
+    def read(self, paths):
+        """Parse one or more linters and add them to the registry.
+
+        :param paths: A path or iterable of paths to linter definitions.
+        """
+        if isinstance(paths, basestring):
+            paths = (paths,)
+
+        for path in paths:
+            self.linters.append(self.parse(path))
+
+    def roll(self, paths, num_procs=None):
+        """Run all of the registered linters against the specified file paths.
+
+        :param paths: An iterable of files and/or directories to lint.
+        :param num_procs: The number of processes to use. Default: cpu count
+        :return: A dictionary with file names as the key, and a list of
+                 :class:`~result.ResultContainer`s as the value.
+        """
+        if not self.linters:
+            raise LintersNotConfigured
+
+        if isinstance(paths, basestring):
+            paths = [paths]
+
+        m = Manager()
+        queue = m.Queue()
+
+        for linter in self.linters:
+            queue.put(linter['path'])
+
+        num_procs = num_procs or cpu_count()
+        num_procs = min(num_procs, len(self.linters))
+
+        # ensure child processes ignore SIGINT so it reaches parent
+        orig = signal.signal(signal.SIGINT, signal.SIG_IGN)
+        pool = Pool(num_procs)
+        signal.signal(signal.SIGINT, orig)
+
+        all_results = defaultdict(list)
+        results = []
+        for i in range(num_procs):
+            results.append(
+                pool.apply_async(_run_worker, args=(queue, paths), kwds=self.lintargs))
+
+        for res in results:
+            # parent process blocks on res.get()
+            for k, v in res.get().iteritems():
+                all_results[k].extend(v)
+
+        return all_results
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/types.py
@@ -0,0 +1,146 @@
+# 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 os
+import re
+from abc import ABCMeta, abstractmethod
+
+from mozpack import path as mozpath
+from mozpack.files import FileFinder
+
+from . import result
+
+
+class BaseType(object):
+    """Abstract base class for all types of linters."""
+    __metaclass__ = ABCMeta
+    batch = False
+
+    def __call__(self, paths, linter, **lintargs):
+        """Run `linter` against `paths` with `lintargs`.
+
+        :param paths: Paths to lint. Can be a file or directory.
+        :param linter: Linter definition paths are being linted against.
+        :param lintargs: External arguments to the linter not defined in
+                         the definition, but passed in by a consumer.
+        :returns: A list of :class:`~result.ResultContainer` objects.
+        """
+        exclude = lintargs.get('exclude', [])
+        exclude.extend(linter.get('exclude', []))
+
+        paths = self._filter(paths, linter.get('include'), exclude)
+        if not paths:
+            return
+
+        if self.batch:
+            return self._lint(paths, linter, **lintargs)
+
+        errors = []
+        for p in paths:
+            result = self._lint(p, linter, **lintargs)
+            if result:
+                errors.extend(result)
+        return errors
+
+    def _filter(self, paths, include=None, exclude=None):
+        if not include and not exclude:
+            return paths
+
+        if include:
+            include = map(os.path.normpath, include)
+
+        if exclude:
+            exclude = map(os.path.normpath, exclude)
+
+        def match(path, patterns):
+            return any(mozpath.match(path, pattern) for pattern in patterns)
+
+        filtered = []
+        for path in paths:
+            if os.path.isfile(path):
+                if include and not match(path, include):
+                    continue
+                elif exclude and match(path, exclude):
+                    continue
+                filtered.append(path)
+            elif os.path.isdir(path):
+                finder = FileFinder(path, find_executables=False, ignore=exclude)
+                if self.batch:
+                    # Batch means the underlying linter will be responsible for finding
+                    # matching files in the directory. Return the path as is if there
+                    # exists at least one matching file.
+                    if any(finder.contains(pattern) for pattern in include):
+                        filtered.append(path)
+                else:
+                    # Convert the directory to a list of matching files.
+                    for pattern in include:
+                        filtered.extend([os.path.join(path, p)
+                                         for p, f in finder.find(pattern)])
+
+        return filtered
+
+    @abstractmethod
+    def _lint(self, path):
+        pass
+
+
+class LineType(BaseType):
+    """Abstract base class for linter types that check each line individually.
+
+    Subclasses of this linter type will read each file and check the provided
+    payload against each line one by one.
+    """
+    __metaclass__ = ABCMeta
+
+    @abstractmethod
+    def condition(payload, line):
+        pass
+
+    def _lint(self, path, linter, **lintargs):
+        payload = linter['payload']
+
+        with open(path, 'r') as fh:
+            lines = fh.readlines()
+
+        errors = []
+        for i, line in enumerate(lines):
+            if self.condition(payload, line):
+                errors.append(result.from_linter(linter, path=path, lineno=i+1))
+
+        return errors
+
+
+class StringType(LineType):
+    """Linter type that checks whether a substring is found."""
+
+    def condition(self, payload, line):
+        return payload in line
+
+
+class RegexType(LineType):
+    """Linter type that checks whether a regex match is found."""
+
+    def condition(self, payload, line):
+        return re.search(payload, line)
+
+
+class ExternalType(BaseType):
+    """Linter type that runs an external function.
+
+    The function is responsible for properly formatting the results
+    into a list of :class:`~result.ResultContainer` objects.
+    """
+    batch = True
+
+    def _lint(self, files, linter, **lintargs):
+        payload = linter['payload']
+        return payload(files, **lintargs)
+
+
+supported_types = {
+    'string': StringType(),
+    'regex': RegexType(),
+    'external': ExternalType(),
+}
+"""Mapping of type string to an associated instance."""
new file mode 100644
--- /dev/null
+++ b/python/mozlint/setup.py
@@ -0,0 +1,26 @@
+# 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 setuptools import setup
+
+VERSION = 0.1
+DEPS = []
+
+setup(
+    name='mozlint',
+    description='Framework for registering and running micro lints',
+    license='MPL 2.0',
+    author='Andrew Halberstadt',
+    author_email='ahalberstadt@mozilla.com',
+    url='',
+    packages=['mozlint'],
+    version=VERSION,
+    classifiers=[
+        'Environment :: Console',
+        'Development Status :: 3 - Alpha',
+        'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)',
+        'Natural Language :: English',
+    ],
+    install_requires=DEPS,
+)
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/files/foobar.js
@@ -0,0 +1,2 @@
+// Oh no.. we called this variable foobar, bad!
+var foobar = "a string";
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/files/no_foobar.js
@@ -0,0 +1,2 @@
+// What a relief
+var properlyNamed = "a string";
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/external.lint
@@ -0,0 +1,30 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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 mozlint import result
+
+
+def lint(files, **lintargs):
+    results = []
+    for path in files:
+        with open(path, 'r') as fh:
+            for i, line in enumerate(fh.readlines()):
+                if 'foobar' in line:
+                    results.append(result.from_linter(
+                        LINTER, path=path, lineno=i+1, column=1, rule="no-foobar"))
+    return results
+
+
+LINTER = {
+    'name': "ExternalLinter",
+    'description': "It's bad to have the string foobar in js files.",
+    'include': [
+        '**/*.js',
+        '**/*.jsm',
+    ],
+    'type': 'external',
+    'payload': lint,
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_exclude.lint
@@ -0,0 +1,10 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "BadExcludeLinter",
+    'description': "Has an invalid exclude directive.",
+    'exclude': [0, 1],  # should be a list of strings
+    'type': 'string',
+    'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_extension.lnt
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "BadExtensionLinter",
+    'description': "Has an invalid file extension.",
+    'type': 'string',
+    'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_include.lint
@@ -0,0 +1,10 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "BadIncludeLinter",
+    'description': "Has an invalid include directive.",
+    'include': 'should be a list',
+    'type': 'string',
+    'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_type.lint
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "BadTypeLinter",
+    'description': "Has an invalid type.",
+    'type': 'invalid',
+    'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/missing_attrs.lint
@@ -0,0 +1,7 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "MissingAttrsLinter",
+    'description': "Missing type and payload",
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/missing_definition.lint
@@ -0,0 +1,4 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+# No LINTER variable
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/raises.lint
@@ -0,0 +1,19 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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 mozlint.errors import LintException
+
+
+def lint(files, **lintargs):
+    raise LintException("Oh no something bad happened!")
+
+
+LINTER = {
+    'name': "RaisesLinter",
+    'description': "Raises an exception",
+    'type': 'external',
+    'payload': lint,
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/regex.lint
@@ -0,0 +1,14 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "RegexLinter",
+    'description': "Make sure the string 'foobar' never appears in a js variable files because it is bad.",
+    'rule': 'no-foobar',
+    'include': [
+        '**/*.js',
+        '**/*.jsm',
+    ],
+    'type': 'regex',
+    'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/string.lint
@@ -0,0 +1,14 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+    'name': "StringLinter",
+    'description': "Make sure the string 'foobar' never appears in browser js files because it is bad.",
+    'rule': 'no-foobar',
+    'include': [
+        '**/*.js',
+        '**/*.jsm',
+    ],
+    'type': 'string',
+    'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_formatters.py
@@ -0,0 +1,84 @@
+# 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 unicode_literals
+
+import json
+import os
+from collections import defaultdict
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint import ResultContainer
+from mozlint import formatters
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestFormatters(TestCase):
+
+    def __init__(self, *args, **kwargs):
+        TestCase.__init__(self, *args, **kwargs)
+
+        containers = (
+            ResultContainer(
+                linter='foo',
+                path='a/b/c.txt',
+                message="oh no foo",
+                lineno=1,
+            ),
+            ResultContainer(
+                linter='bar',
+                path='d/e/f.txt',
+                message="oh no bar",
+                hint="try baz instead",
+                level='warning',
+                lineno=4,
+                column=2,
+                rule="bar-not-allowed",
+            ),
+            ResultContainer(
+                linter='baz',
+                path='a/b/c.txt',
+                message="oh no baz",
+                lineno=4,
+                source="if baz:",
+            ),
+        )
+
+        self.results = defaultdict(list)
+        for c in containers:
+            self.results[c.path].append(c)
+
+    def test_stylish_formatter(self):
+        expected = """
+a/b/c.txt
+  1:1  error  oh no foo  (foo)
+  4:1  error  oh no baz  (baz)
+
+d/e/f.txt
+  4:2  warning  oh no bar  bar-not-allowed (bar)
+
+\u2716 3 problems (2 errors, 1 warning)
+""".strip()
+
+        fmt = formatters.get('stylish', disable_colors=True)
+        self.assertEqual(expected, fmt(self.results))
+
+    def test_json_formatter(self):
+        fmt = formatters.get('json')
+        formatted = json.loads(fmt(self.results))
+
+        self.assertEqual(set(formatted.keys()), set(self.results.keys()))
+
+        slots = ResultContainer.__slots__
+        for errors in formatted.values():
+            for err in errors:
+                self.assertTrue(all(s in err for s in slots))
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_parser.py
@@ -0,0 +1,68 @@
+# 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 os
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint.parser import Parser
+from mozlint.errors import (
+    LinterNotFound,
+    LinterParseError,
+)
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestParser(TestCase):
+
+    def __init__(self, *args, **kwargs):
+        TestCase.__init__(self, *args, **kwargs)
+
+        self._lintdir = os.path.join(here, 'linters')
+        self._parse = Parser()
+
+    def parse(self, name):
+        return self._parse(os.path.join(self._lintdir, name))
+
+    def test_parse_valid_linter(self):
+        linter = self.parse('string.lint')
+        self.assertIsInstance(linter, dict)
+        self.assertIn('name', linter)
+        self.assertIn('description', linter)
+        self.assertIn('type', linter)
+        self.assertIn('payload', linter)
+
+    def test_parse_invalid_type(self):
+        with self.assertRaises(LinterParseError):
+            self.parse('invalid_type.lint')
+
+    def test_parse_invalid_extension(self):
+        with self.assertRaises(LinterParseError):
+            self.parse('invalid_extension.lnt')
+
+    def test_parse_invalid_include_exclude(self):
+        with self.assertRaises(LinterParseError):
+            self.parse('invalid_include.lint')
+
+        with self.assertRaises(LinterParseError):
+            self.parse('invalid_exclude.lint')
+
+    def test_parse_missing_attributes(self):
+        with self.assertRaises(LinterParseError):
+            self.parse('missing_attrs.lint')
+
+    def test_parse_missing_definition(self):
+        with self.assertRaises(LinterParseError):
+            self.parse('missing_definition.lint')
+
+    def test_parse_non_existent_linter(self):
+        with self.assertRaises(LinterNotFound):
+            self.parse('missing_file.lint')
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_roller.py
@@ -0,0 +1,75 @@
+# 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 os
+import sys
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint import LintRoller, ResultContainer
+from mozlint.errors import LintersNotConfigured, LintException
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestLintRoller(TestCase):
+
+    def __init__(self, *args, **kwargs):
+        TestCase.__init__(self, *args, **kwargs)
+
+        filedir = os.path.join(here, 'files')
+        self.files = [os.path.join(filedir, f) for f in os.listdir(filedir)]
+        self.lintdir = os.path.join(here, 'linters')
+
+        names = ('string.lint', 'regex.lint', 'external.lint')
+        self.linters = [os.path.join(self.lintdir, n) for n in names]
+
+    def setUp(self):
+        TestCase.setUp(self)
+        self.lint = LintRoller()
+
+    def test_roll_no_linters_configured(self):
+        with self.assertRaises(LintersNotConfigured):
+            self.lint.roll(self.files)
+
+    def test_roll_successful(self):
+        self.lint.read(self.linters)
+
+        result = self.lint.roll(self.files)
+        self.assertEqual(len(result), 1)
+
+        path = result.keys()[0]
+        self.assertEqual(os.path.basename(path), 'foobar.js')
+
+        errors = result[path]
+        self.assertIsInstance(errors, list)
+        self.assertEqual(len(errors), 6)
+
+        container = errors[0]
+        self.assertIsInstance(container, ResultContainer)
+        self.assertEqual(container.rule, 'no-foobar')
+
+    def test_roll_catch_exception(self):
+        self.lint.read(os.path.join(self.lintdir, 'raises.lint'))
+
+        # suppress printed traceback from test output
+        old_stderr = sys.stderr
+        sys.stderr = open(os.devnull, 'w')
+        with self.assertRaises(LintException):
+            self.lint.roll(self.files)
+        sys.stderr = old_stderr
+
+    def test_roll_with_excluded_path(self):
+        self.lint.lintargs = {'exclude': ['**/foobar.js']}
+
+        self.lint.read(self.linters)
+        result = self.lint.roll(self.files)
+
+        self.assertEqual(len(result), 0)
+
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_types.py
@@ -0,0 +1,69 @@
+# 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 os
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint import LintRoller
+from mozlint.result import ResultContainer
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestLinterTypes(TestCase):
+
+    def __init__(self, *args, **kwargs):
+        TestCase.__init__(self, *args, **kwargs)
+
+        self.lintdir = os.path.join(here, 'linters')
+        self.filedir = os.path.join(here, 'files')
+        self.files = [os.path.join(self.filedir, f) for f in os.listdir(self.filedir)]
+
+    def setUp(self):
+        TestCase.setUp(self)
+        self.lint = LintRoller()
+
+    def path(self, name):
+        return os.path.join(self.filedir, name)
+
+    def test_string_linter(self):
+        self.lint.read(os.path.join(self.lintdir, 'string.lint'))
+        result = self.lint.roll(self.files)
+        self.assertIsInstance(result, dict)
+
+        self.assertIn(self.path('foobar.js'), result.keys())
+        self.assertNotIn(self.path('no_foobar.js'), result.keys())
+
+        result = result[self.path('foobar.js')][0]
+        self.assertIsInstance(result, ResultContainer)
+        self.assertEqual(result.linter, 'StringLinter')
+
+    def test_regex_linter(self):
+        self.lint.read(os.path.join(self.lintdir, 'regex.lint'))
+        result = self.lint.roll(self.files)
+        self.assertIsInstance(result, dict)
+        self.assertIn(self.path('foobar.js'), result.keys())
+        self.assertNotIn(self.path('no_foobar.js'), result.keys())
+
+        result = result[self.path('foobar.js')][0]
+        self.assertIsInstance(result, ResultContainer)
+        self.assertEqual(result.linter, 'RegexLinter')
+
+    def test_external_linter(self):
+        self.lint.read(os.path.join(self.lintdir, 'external.lint'))
+        result = self.lint.roll(self.files)
+        self.assertIsInstance(result, dict)
+        self.assertIn(self.path('foobar.js'), result.keys())
+        self.assertNotIn(self.path('no_foobar.js'), result.keys())
+
+        result = result[self.path('foobar.js')][0]
+        self.assertIsInstance(result, ResultContainer)
+        self.assertEqual(result.linter, 'ExternalLinter')
+
+
+if __name__ == '__main__':
+    main()