1from pyflakes.checker import Binding, Assignment, Checker
2from pyflakes.api import isPythonFile
3import _ast
4import re
5import py
6import pytest
7import sys
8import tokenize
9
10
11def assignment_monkeypatched_init(self, name, source):
12    Binding.__init__(self, name, source)
13    if name == '__tracebackhide__':
14        self.used = True
15
16Assignment.__init__ = assignment_monkeypatched_init
17
18
19HISTKEY = "flakes/mtimes"
20
21
22def pytest_addoption(parser):
23    group = parser.getgroup("general")
24    group.addoption(
25        '--flakes', action='store_true',
26        help="run pyflakes on .py files")
27    parser.addini(
28        "flakes-ignore", type="linelist",
29        help="each line specifies a glob pattern and whitespace "
30             "separated pyflakes errors which will be ignored, "
31             "example: *.py UnusedImport")
32
33
34def pytest_configure(config):
35    if config.option.flakes:
36        config._flakes = FlakesPlugin(config)
37        config.pluginmanager.register(config._flakes)
38    config.addinivalue_line('markers', "flakes: Tests which run flake.")
39
40
41class FlakesPlugin(object):
42    def __init__(self, config):
43        self.ignore = Ignorer(config.getini("flakes-ignore"))
44        self.mtimes = config.cache.get(HISTKEY, {})
45
46    def pytest_collect_file(self, path, parent):
47        config = parent.config
48        if config.option.flakes and isPythonFile(path.strpath):
49            flakesignore = self.ignore(path)
50            if flakesignore is not None:
51                if hasattr(FlakesItem, 'from_parent'):
52                    item = FlakesItem.from_parent(parent,
53                                                  fspath=path,
54                                                  flakesignore=flakesignore)
55                else:
56                    item = FlakesItem(path, parent, flakesignore)
57                return item
58
59    def pytest_sessionfinish(self, session):
60        session.config.cache.set(HISTKEY, self.mtimes)
61
62
63class FlakesError(Exception):
64    """ indicates an error during pyflakes checks. """
65
66
67class FlakesItem(pytest.Item, pytest.File):
68
69    def __init__(self, fspath, parent, flakesignore):
70        super(FlakesItem, self).__init__(fspath, parent)
71        if hasattr(self, 'add_marker'):
72            self.add_marker("flakes")
73        else:
74            self.keywords["flakes"] = True
75        self.flakesignore = flakesignore
76
77    def setup(self):
78        flakesmtimes = self.config._flakes.mtimes
79        self._flakesmtime = self.fspath.mtime()
80        old = flakesmtimes.get(self.nodeid, 0)
81        if old == [self._flakesmtime, self.flakesignore]:
82            pytest.skip("file(s) previously passed pyflakes checks")
83
84    def runtest(self):
85        found_errors, out = check_file(self.fspath, self.flakesignore)
86        if found_errors:
87            raise FlakesError("\n".join(out))
88        # update mtime only if test passed
89        # otherwise failures would not be re-run next time
90        self.config._flakes.mtimes[self.nodeid] = [self._flakesmtime, self.flakesignore]
91
92    def repr_failure(self, excinfo):
93        if excinfo.errisinstance(FlakesError):
94            return excinfo.value.args[0]
95        return super(FlakesItem, self).repr_failure(excinfo)
96
97    def reportinfo(self):
98        if self.flakesignore:
99            ignores = "(ignoring %s)" % " ".join(self.flakesignore)
100        else:
101            ignores = ""
102        return (self.fspath, -1, "pyflakes-check%s" % ignores)
103
104
105class Ignorer:
106    def __init__(self, ignorelines, coderex=re.compile(r"[EW]\d\d\d")):
107        self.ignores = ignores = []
108        for line in ignorelines:
109            i = line.find("#")
110            if i != -1:
111                line = line[:i]
112            try:
113                glob, ign = line.split(None, 1)
114            except ValueError:
115                glob, ign = None, line
116            if glob and coderex.match(glob):
117                glob, ign = None, line
118            ign = ign.split()
119            if "ALL" in ign:
120                ign = None
121            ignores.append((glob, ign))
122
123    def __call__(self, path):
124        l = set()
125        for (glob, ignlist) in self.ignores:
126            if not glob or path.fnmatch(glob):
127                if ignlist is None:
128                    return None
129                l.update(set(ignlist))
130        return sorted(l)
131
132
133def check_file(path, flakesignore):
134    if not hasattr(tokenize, 'open'):
135        codeString = path.read()
136    else:
137        with tokenize.open(path.strpath) as f:
138            codeString = f.read()
139    filename = py.builtin._totext(path)
140    errors = []
141    try:
142        tree = compile(codeString, filename, "exec", _ast.PyCF_ONLY_AST)
143    except SyntaxError:
144        value = sys.exc_info()[1]
145        (lineno, offset, text) = value.lineno, value.offset, value.text
146        if text is None:
147            errors.append("%s: problem decoding source" % filename)
148        else:
149            line = text.splitlines()[-1]
150
151            if offset is not None:
152                offset = offset - (len(text) - len(line))
153
154            msg = '%s:%d: %s' % (filename, lineno, value.args[0])
155            msg = "%s\n%s" % (msg, line)
156
157            if offset is not None:
158                msg = "%s\n%s" % (msg, "%s^" % (" " * offset))
159            errors.append(msg)
160        return 1, errors
161    else:
162        # Okay, it's syntactically valid.  Now check it.
163        w = Checker(tree, filename)
164        w.messages.sort(key=lambda m: m.lineno)
165        lines = codeString.split('\n')
166        for warning in w.messages:
167            if warning.__class__.__name__ in flakesignore or is_ignored_line(lines[warning.lineno - 1].strip()):
168                continue
169            errors.append(
170                '%s:%s: %s\n%s' % (
171                    warning.filename,
172                    warning.lineno,
173                    warning.__class__.__name__,
174                    warning.message % warning.message_args))
175        return len(errors), errors
176
177
178def is_ignored_line(line):
179    if line.endswith('# noqa') or line.endswith('# pragma: no flakes'):
180        return True
181    return False
182