Skip to content

Commit

Permalink
Merge c83147c into 4c50f11
Browse files Browse the repository at this point in the history
  • Loading branch information
carsongee committed Apr 25, 2020
2 parents 4c50f11 + c83147c commit b5f2dfc
Show file tree
Hide file tree
Showing 8 changed files with 142 additions and 34 deletions.
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,13 @@ target/

# PyEnv
.python-version

# Virtualenv
.venv
.pytest-pylint

# Pycharm
.idea

# Vscode
.vscode
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ matrix:
- python: 3.6
- python: 3.5
install:
- "pip install tox-travis tox==3.14.0 coveralls"
- "pip install tox-travis tox==3.14.6 coveralls"
- "pip install -e ."
script: tox
after_success:
Expand Down
50 changes: 50 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@

Helpers for running pylint with py.test and have configurable rule
types (i.e. Convention, Warn, and Error) fail the
build. You can also specify a pylintrc file.

How it works

We have a thin plugin wrapper that is installed through setup.py hooks as `pylint`.
This wrapper uses pytest_addoption and pytest_configure to decide to configure and
register the real plugin PylintPlygin

Once it is registered in `pytest_configure`, the hooks already executed
by previous plugins will run. For instance, in case PylintPlugin had
`pytest_addoption` implemented, which runs before `pytest_configure`
in the hook cycle, it would be executed once PylintPlugin got registered.

PylintPlugin uses the `pytest_collect_file` hook which is called wih every
file available in the test target dir. This hook collects all the file
pylint should run on, in this case files with extension ".py".

`pytest_collect_file` hook returns a collection of Node, or None. In
py.test context, Node being a base class that defines py.test Collection
Tree.

A Node can be a subclass of Collector, which has children, or an Item, which
is a leaf node.

A practical example would be, a Python test file (Collector), can have multiple
test functions (multiple Items)

For this plugin, the relatioship of File to Item is one to one, one
file represents one pylint result.

From that, there are two important classes: PyLintFile, and PyLintItem.

PyLintFile represents a python file, extension ".py", that was
collected based on target directory as mentioned previously.

PyLintItem represents one file which pylint was ran or will run.

Back to PylintPlugin, `pytest_collection_finish` hook will run after the
collection phase where pylint will be ran on the collected files.

Based on the ProgrammaticReporter, the result is stored in a dictionary
with the file relative path of the file being the key, and a list of
errors related to the file.

All PylintFile returned during `pytest_collect_file`, returns an one
element list of PyLintItem. The Item implements runtest method which will
get the pylint messages per file and expose to the user.
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ Acknowledgements
This code is heavily based on
`pytest-flakes <https://github.com/fschulze/pytest-flakes>`__

Development
===========

If you want to help development, there is
`overview documentation <https://github.com/carsongee/pytest-pylint//lob/master/DEVELOPMENT.rst>`_

Releases
========
Expand Down
91 changes: 64 additions & 27 deletions pytest_pylint/plugin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# -*- coding: utf-8 -*-
"""Pylint plugin for py.test"""
"""
pytest plugins. Both pylint wrapper and PylintPlugin
from os.path import exists, join, dirname
"""


from collections import defaultdict
from configparser import ConfigParser, NoSectionError, NoOptionError
from os.path import exists, join, dirname

from pylint import lint
from pylint.config import PYLINTRC
Expand All @@ -14,6 +19,7 @@

HISTKEY = 'pylint/mtimes'
FILL_CHARS = 80
MARKER = 'pylint'


def pytest_addoption(parser):
Expand Down Expand Up @@ -69,7 +75,10 @@ def pytest_configure(config):
:param _pytest.config.Config config: pytest config object
"""
config.addinivalue_line('markers', "pylint: Tests which run pylint.")
config.addinivalue_line(
'markers',
"{0}: Tests which run pylint.".format(MARKER)
)
if config.option.pylint and not config.option.no_pylint:
pylint_plugin = PylintPlugin(config)
config.pluginmanager.register(pylint_plugin)
Expand All @@ -87,7 +96,7 @@ def __init__(self, config):
self.mtimes = {}

self.pylint_files = set()
self.pylint_messages = {}
self.pylint_messages = defaultdict(list)
self.pylint_config = None
self.pylintrc_file = None
self.pylint_ignore = []
Expand Down Expand Up @@ -196,7 +205,9 @@ def pytest_collect_file(self, path, parent):
if should_include_file(
rel_path, self.pylint_ignore, self.pylint_ignore_patterns
):
item = PyLintItem(path, parent, pylint_plugin=self)
item = PylintFile.from_parent(
parent, fspath=path, plugin=self
)
else:
return None

Expand Down Expand Up @@ -244,42 +255,66 @@ def pytest_collection_finish(self, session):
messages = result.linter.reporter.data
# Stores the messages in a dictionary for lookup in tests.
for message in messages:
if message.path not in self.pylint_messages:
self.pylint_messages[message.path] = []
self.pylint_messages[message.path].append(message)
print('-' * FILL_CHARS)


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, pylint_plugin):
super().__init__(fspath, parent)

self.add_marker('pylint')
self.rel_path = get_rel_path(
class PylintFile(pytest.File):
"""File that pylint will run on."""
rel_path = None # : str
plugin = None # : PylintPlugin
should_skip = false # : bool
mtime = None # : float

@classmethod
def from_parent(cls, parent, *, fspath, plugin):
# We add plugin to get plugin level information so the
# signature differs pylint: disable=arguments-differ
_self = getattr(super(), 'from_parent', cls)(parent, fspath=fspath)
_self.plugin = plugin

_self.rel_path = get_rel_path(
fspath.strpath,
parent.session.fspath.strpath
)
self.plugin = pylint_plugin
_self.mtime = fspath.mtime()
prev_mtime = _self.plugin.mtimes.get(_self.rel_path, 0)
_self.should_skip = (prev_mtime == _self.mtime)

return _self

def collect(self):
"""Create a PyLintItem for the File."""
yield PyLintItem.from_parent(
parent=self,
name='{}::PYLINT'.format(self.fspath)
)


class PyLintItem(pytest.Item):
"""pylint test running class."""

parent = None # : PylintFile
plugin = None # : PylintPlugin

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_marker(MARKER)
self.plugin = self.parent.plugin

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

self._nodeid += '::PYLINT'
self.mtime = self.fspath.mtime()
prev_mtime = self.plugin.mtimes.get(self.rel_path, 0)
self.should_skip = (prev_mtime == self.mtime)
@classmethod
def from_parent(cls, parent, **kw):
return getattr(super(), 'from_parent', cls)(parent, **kw)

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

def runtest(self):
Expand All @@ -288,7 +323,9 @@ def runtest(self):

def _loop_errors(writer):
reported_errors = []
for error in self.plugin.pylint_messages.get(self.rel_path, []):
for error in self.plugin.pylint_messages.get(
self.parent.rel_path, []
):
if error.C in self.config.option.pylint_error_types:
reported_errors.append(
error.format(self._msg_format)
Expand Down Expand Up @@ -319,7 +356,7 @@ def _loop_errors(writer):
raise PyLintException('\n'.join(reported_errors))

# Update the cache if the item passed pylint.
self.plugin.mtimes[self.rel_path] = self.mtime
self.plugin.mtimes[self.parent.rel_path] = self.parent.mtime

def repr_failure(self, excinfo, style=None):
"""Handle any test failures by checking that they were ours."""
Expand All @@ -330,4 +367,4 @@ def repr_failure(self, excinfo, style=None):

def reportinfo(self):
"""Generate our test report"""
return self.fspath, None, "[pylint] {0}".format(self.rel_path)
return self.fspath, None, "[pylint] {0}".format(self.parent.rel_path)
9 changes: 8 additions & 1 deletion pytest_pylint/tests/test_pytest_pylint.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"""
import os
from textwrap import dedent
from unittest import mock

import mock
import pytest


Expand All @@ -24,6 +24,13 @@ def test_basic(testdir):
assert 'Linting files' in result.stdout.str()


def test_nodeid(testdir):
"""Verify our nodeid adds a suffix"""
testdir.makepyfile('import sys')
result = testdir.runpytest('--pylint', '--collectonly')
assert '::PYLINT' in result.stdout.str()


def test_subdirectories(testdir):
"""Verify pylint checks files in subdirectories"""
subdir = testdir.mkpydir('mymodule')
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
python_requires=">=3.5",
install_requires=['pytest>=5.0', 'pylint>=2.0.0', 'toml>=0.7.1'],
setup_requires=['pytest-runner'],
tests_require=['mock', 'coverage', 'pytest-pep8'],
tests_require=['coverage', 'pytest-flake8'],
classifiers=[
'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers',
Expand Down
7 changes: 3 additions & 4 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ usedevelop = true
deps =
pylint
pytest
pytest-pep8
pytest-flake8
coverage
mock
commands =
coverage erase
coverage run -m py.test {posargs}
coverage report
coverage html -d htmlcov

[pytest]
addopts = --pylint --pep8
markers = pep8
addopts = --pylint --flake8
markers = flake8

0 comments on commit b5f2dfc

Please sign in to comment.