File: pytest_pylint.py

package info (click to toggle)
pytest-pylint 0.14.1-1
  • links: PTS, VCS
  • area: main
  • in suites: bullseye, sid
  • size: 180 kB
  • sloc: python: 579; makefile: 9
file content (299 lines) | stat: -rw-r--r-- 9,592 bytes parent folder | download | duplicates (2)
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
"""Pylint plugin for py.test"""
from __future__ import absolute_import, print_function, unicode_literals
import re
from os import sep
from os.path import exists, join, dirname
import sys
from six.moves.configparser import (  # pylint: disable=import-error
    ConfigParser,
    NoSectionError,
    NoOptionError
)

from pylint import lint
from pylint.config import PYLINTRC
from pylint.interfaces import IReporter
from pylint.reporters import BaseReporter
import pytest

HISTKEY = 'pylint/mtimes'


class PyLintException(Exception):
    """Exception to raise if a file has a specified pylint error"""


class ProgrammaticReporter(BaseReporter):
    """Reporter that replaces output with storage in list of dictionaries"""

    __implements__ = IReporter
    extension = 'prog'

    def __init__(self, output=None):
        BaseReporter.__init__(self, output)
        self.current_module = None
        self.data = []

    def add_message(self, msg_id, location, msg):
        """Deprecated, but required"""
        raise NotImplementedError

    def handle_message(self, msg):
        """Get message and append to our data structure"""
        self.data.append(msg)

    def _display(self, layout):
        """launch layouts display"""

    def on_set_current_module(self, module, filepath):
        """Hook called when a module starts to be analysed."""
        print('.', end='')
        sys.stdout.flush()

    def on_close(self, stats, previous_stats):
        """Hook called when all modules finished analyzing."""
        # print a new line when pylint is finished
        print('')


def get_rel_path(path, parent_path):
    """
    Give the path to object relative to ``parent_path``.
    """
    replaced_path = path.replace(parent_path, '', 1)
    if replaced_path[0] == sep:
        rel_path = replaced_path[1:]
    else:
        rel_path = replaced_path
    return rel_path


def pytest_addoption(parser):
    """Add all our command line options"""
    group = parser.getgroup("general")
    group.addoption(
        "--pylint",
        action="store_true", default=False,
        help="run pylint on all"
    )
    group.addoption(
        "--no-pylint",
        action="store_true", default=False,
        help="disable running pylint "
    )

    group.addoption(
        '--pylint-rcfile',
        default=None,
        help='Location of RC file if not pylintrc'
    )
    group.addoption(
        '--pylint-error-types',
        default='CRWEF',
        help='The types of pylint errors to consider failures by letter'
        ', default is all of them (CRWEF).'
    )
    group.addoption(
        '--pylint-jobs',
        default=None,
        help='Specify number of processes to use for pylint'
    )


def pytest_sessionstart(session):
    """Storing pylint settings on the session"""
    session.pylint_files = set()
    session.pylint_messages = {}
    session.pylint_config = None
    session.pylintrc_file = None
    session.pylint_ignore = []
    session.pylint_ignore_patterns = []
    session.pylint_msg_template = None
    config = session.config

    # Find pylintrc to check ignore list
    pylintrc_file = config.option.pylint_rcfile or PYLINTRC

    if pylintrc_file and not exists(pylintrc_file):
        # The directory of pytest.ini got a chance
        pylintrc_file = join(dirname(str(config.inifile)), pylintrc_file)

    if pylintrc_file and exists(pylintrc_file):
        session.pylintrc_file = pylintrc_file
        session.pylint_config = ConfigParser()
        session.pylint_config.read(pylintrc_file)

        try:
            ignore_string = session.pylint_config.get('MASTER', 'ignore')
            if ignore_string:
                session.pylint_ignore = ignore_string.split(',')
        except (NoSectionError, NoOptionError):
            pass

        try:
            session.pylint_ignore_patterns = session.pylint_config.get(
                'MASTER', 'ignore-patterns')
        except (NoSectionError, NoOptionError):
            pass

        try:
            session.pylint_msg_template = session.pylint_config.get(
                'REPORTS', 'msg-template'
            )
        except (NoSectionError, NoOptionError):
            pass


def include_file(path, ignore_list, ignore_patterns=None):
    """Checks if a file should be included in the collection."""
    if ignore_patterns:
        for pattern in ignore_patterns:
            if re.match(pattern, path):
                return False
    parts = path.split(sep)
    return not set(parts) & set(ignore_list)


def pytest_configure(config):
    """
    Add a plugin to cache file mtimes.

    :param _pytest.config.Config config: pytest config object
    """
    if config.option.pylint:
        config.pylint = PylintPlugin(config)
        config.pluginmanager.register(config.pylint)
    config.addinivalue_line('markers', "pylint: Tests which run pylint.")


class PylintPlugin(object):
    """
    A Plugin object for pylint, which loads and records file mtimes.
    """
    # pylint: disable=too-few-public-methods

    def __init__(self, config):
        self.mtimes = config.cache.get(HISTKEY, {})

    def pytest_sessionfinish(self, session):
        """
        Save file mtimes to pytest cache.

        :param _pytest.main.Session session: the pytest session object
        """
        session.config.cache.set(HISTKEY, self.mtimes)


def pytest_collect_file(path, parent):
    """Collect files on which pylint should run"""
    config = parent.session.config
    if not config.option.pylint or config.option.no_pylint:
        return None
    if path.ext != ".py":
        return None
    rel_path = get_rel_path(path.strpath, parent.session.fspath.strpath)
    session = parent.session
    if session.pylint_config is None:
        # No pylintrc, therefore no ignores, so return the item.
        item = PyLintItem(path, parent)
    elif include_file(rel_path, session.pylint_ignore,
                      session.pylint_ignore_patterns):
        item = PyLintItem(
            path, parent, session.pylint_msg_template, session.pylintrc_file
        )
    else:
        return None
    if not item.should_skip:
        session.pylint_files.add(rel_path)
    return item


def pytest_collection_finish(session):
    """Lint collected files and store messages on session."""
    if not session.pylint_files:
        return

    jobs = session.config.option.pylint_jobs
    reporter = ProgrammaticReporter()
    # Build argument list for pylint
    args_list = list(session.pylint_files)
    if session.pylintrc_file:
        args_list.append('--rcfile={0}'.format(
            session.pylintrc_file
        ))
    if jobs is not None:
        args_list.append('-j')
        args_list.append(jobs)
    print('-' * 65)
    print('Linting files')
    # Disabling keyword arg to handle both 1.x and 2.x pylint API calls
    # pylint: disable=unexpected-keyword-arg

    # Run pylint over the collected files.
    try:
        result = lint.Run(args_list, reporter=reporter, exit=False)
    except TypeError:  # Handle pylint 2.0 API
        result = lint.Run(args_list, reporter=reporter, do_exit=False)
    messages = result.linter.reporter.data
    # Stores the messages in a dictionary for lookup in tests.
    for message in messages:
        if message.path not in session.pylint_messages:
            session.pylint_messages[message.path] = []
        session.pylint_messages[message.path].append(message)
    print('-' * 65)


# pylint: disable=abstract-method
class PyLintItem(pytest.Item, pytest.File):
    """pylint test running class."""
    # pylint doesn't deal well with dynamic modules and there isn't an
    # astng plugin for pylint in pypi yet, so we'll have to disable
    # the checks.
    # pylint: disable=no-member,abstract-method
    def __init__(self, fspath, parent, msg_format=None, pylintrc_file=None):
        super(PyLintItem, self).__init__(fspath, parent)

        self.add_marker("pylint")
        self.rel_path = get_rel_path(
            fspath.strpath,
            parent.session.fspath.strpath
        )

        if msg_format is None:
            self._msg_format = '{C}:{line:3d},{column:2d}: {msg} ({symbol})'
        else:
            self._msg_format = msg_format

        self.pylintrc_file = pylintrc_file
        self.__mtime = self.fspath.mtime()
        prev_mtime = self.config.pylint.mtimes.get(self.nodeid, 0)
        self.should_skip = (prev_mtime == self.__mtime)

    def setup(self):
        """Mark unchanged files as SKIPPED."""
        if self.should_skip:
            pytest.skip("file(s) previously passed pylint checks")

    def runtest(self):
        """Check the pylint messages to see if any errors were reported."""
        reported_errors = []
        for error in self.session.pylint_messages.get(self.rel_path, []):
            if error.C in self.config.option.pylint_error_types:
                reported_errors.append(
                    error.format(self._msg_format)
                )
        if reported_errors:
            raise PyLintException('\n'.join(reported_errors))

        # Update the cache if the item passed pylint.
        self.config.pylint.mtimes[self.nodeid] = self.__mtime

    def repr_failure(self, excinfo):
        """Handle any test failures by checkint that they were ours."""
        if excinfo.errisinstance(PyLintException):
            return excinfo.value.args[0]
        return super(PyLintItem, self).repr_failure(excinfo)

    def reportinfo(self):
        """Generate our test report"""
        return self.fspath, None, "[pylint] {0}".format(self.rel_path)