1# https://docs.pytest.org/en/latest/writing_plugins.html
2# https://docs.pytest.org/en/latest/example/nonpython.html#yaml-plugin
3
4import optparse
5
6import py.io
7import pycodestyle
8import pytest
9
10
11def pytest_addoption(parser):
12    group = parser.getgroup('pycodestyle')
13    group.addoption('--pycodestyle', action='store_true',
14                    default=False, help='run pycodestyle')
15
16
17def pytest_configure(config):
18    config.addinivalue_line('markers', 'pycodestyle: mark tests to be checked by pycodestyle.')
19
20
21def pytest_collect_file(parent, path):
22    config = parent.config
23    if config.getoption('pycodestyle') and path.ext == '.py':
24        # https://github.com/PyCQA/pycodestyle/blob/2.5.0/pycodestyle.py#L2295
25        style_guide = pycodestyle.StyleGuide(paths=[str(path)], verbose=False)
26        if not style_guide.excluded(filename=str(path)):
27            # https://github.com/pytest-dev/pytest/blob/ee1950af7793624793ee297e5f48b49c8bdf2065/src/_pytest/nodes.py#L477
28            return File.from_parent(parent=parent, fspath=path, style_guide_options=style_guide.options)
29
30
31class File(pytest.File):
32
33    @classmethod
34    def from_parent(cls, parent, fspath, style_guide_options: optparse.Values):
35        _file = super().from_parent(parent=parent, fspath=fspath)
36        # store options of pycodestyle
37        _file.style_guide_options = style_guide_options
38        return _file
39
40    def collect(self):
41        # https://github.com/pytest-dev/pytest/blob/ee1950af7793624793ee297e5f48b49c8bdf2065/src/_pytest/nodes.py#L399
42        yield Item.from_parent(parent=self, name=self.name, nodeid=self.nodeid)
43
44
45class Item(pytest.Item):
46    CACHE_KEY = 'pycodestyle/mtimes'
47
48    def __init__(self, name, parent, nodeid):
49        # https://github.com/pytest-dev/pytest/blob/ee1950af7793624793ee297e5f48b49c8bdf2065/src/_pytest/nodes.py#L544
50        super().__init__(name, parent=parent, nodeid=f"{nodeid}::PYCODESTYLE")
51        self.add_marker('pycodestyle')
52        # load options of pycodestyle
53        self.options: optparse.Values = self.parent.style_guide_options
54
55    def setup(self):
56        if not hasattr(self.config, 'cache'):
57            return
58
59        old_mtime = self.config.cache.get(self.CACHE_KEY, {}).get(str(self.fspath), -1)
60        mtime = self.fspath.mtime()
61        if old_mtime == mtime:
62            pytest.skip('previously passed pycodestyle checks')
63
64    def runtest(self):
65        # http://pycodestyle.pycqa.org/en/latest/api.html#pycodestyle.Checker
66        # http://pycodestyle.pycqa.org/en/latest/advanced.html
67        checker = pycodestyle.Checker(filename=str(self.fspath),
68                                      options=self.options)
69        file_errors, out, err = py.io.StdCapture.call(checker.check_all)
70        if file_errors > 0:
71            raise PyCodeStyleError(out)
72        elif hasattr(self.config, 'cache'):
73            # update cache
74            # http://pythonhosted.org/pytest-cache/api.html
75            cache = self.config.cache.get(self.CACHE_KEY, {})
76            cache[str(self.fspath)] = self.fspath.mtime()
77            self.config.cache.set(self.CACHE_KEY, cache)
78
79    def repr_failure(self, excinfo):
80        if excinfo.errisinstance(PyCodeStyleError):
81            return excinfo.value.args[0]
82        else:
83            return super().repr_failure(excinfo)
84
85    def reportinfo(self):
86        # https://github.com/pytest-dev/pytest/blob/4678cbeb913385f00cc21b79662459a8c9fafa87/_pytest/main.py#L550
87        # https://github.com/pytest-dev/pytest/blob/4678cbeb913385f00cc21b79662459a8c9fafa87/_pytest/doctest.py#L149
88        return self.fspath, None, 'pycodestyle-check'
89
90
91class PyCodeStyleError(Exception):
92    """custom exception for error reporting."""
93