├── pytest_pylint ├── __init__.py ├── util.py ├── pylint_util.py ├── tests │ ├── test_util.py │ └── test_pytest_pylint.py └── plugin.py ├── .coveragerc ├── setup.cfg ├── pylintrc ├── MANIFEST.in ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── tox.ini ├── setup.py ├── DEVELOPMENT.rst └── README.rst /pytest_pylint/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = . 3 | parallel = True 4 | omit = 5 | .tox/* 6 | setup.py 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [flake8] 5 | max-line-length = 88 6 | extend-ignore = E203, W503, E231 7 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = C0330, C0326 3 | 4 | [FORMAT] 5 | max-line-length = 88 6 | 7 | [TYPECHECK] 8 | ignored-classes = pytest 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include pytest_pylint 4 | graft pytest_pylint/tests 5 | global-exclude __pycache__ 6 | global-exclude *.py[cod] 7 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: [push, pull_request] 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: 12 | - "3.8" 13 | - "3.9" 14 | - "3.10" 15 | - "3.11" 16 | - "3.12" 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip setuptools wheel 29 | python -m pip install --upgrade tox tox-py 30 | 31 | - name: Run tox targets for ${{ matrix.python-version }} 32 | run: tox --py current 33 | 34 | - name: Run coverage 35 | run: tox -e coverage 36 | 37 | - name: Run linters 38 | run: tox -e qa 39 | -------------------------------------------------------------------------------- /pytest_pylint/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Utility functions for gathering files, etc. 4 | """ 5 | import re 6 | from os import sep 7 | 8 | 9 | class PyLintException(Exception): 10 | """Exception to raise if a file has a specified pylint error""" 11 | 12 | 13 | def get_rel_path(path, parent_path): 14 | """ 15 | Give the path to object relative to ``parent_path``. 16 | """ 17 | replaced_path = path.replace(parent_path, "", 1) 18 | 19 | if replaced_path[0] == sep and replaced_path != path: 20 | rel_path = replaced_path[1:] 21 | else: 22 | rel_path = replaced_path 23 | return rel_path 24 | 25 | 26 | def should_include_file(path, ignore_list, ignore_patterns=None): 27 | """Checks if a file should be included in the collection.""" 28 | if ignore_patterns: 29 | for pattern in ignore_patterns: 30 | if re.match(pattern, path): 31 | return False 32 | parts = path.split(sep) 33 | return not set(parts) & set(ignore_list) 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | .pytest_cache/ 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # PyEnv 61 | .python-version 62 | 63 | # Virtualenv 64 | .venv 65 | 66 | # Pycharm 67 | .idea 68 | 69 | # Vscode 70 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Carson Gee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pytest_pylint/pylint_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Pylint reporter classes.""" 3 | import sys 4 | 5 | from pylint.reporters import BaseReporter 6 | 7 | 8 | class ProgrammaticReporter(BaseReporter): 9 | """Reporter that replaces output with storage in list of dictionaries""" 10 | 11 | extension = "prog" 12 | 13 | def __init__(self, output=None): 14 | BaseReporter.__init__(self, output) 15 | self.current_module = None 16 | self.data = [] 17 | 18 | def add_message(self, msg_id, location, msg): 19 | """Deprecated, but required""" 20 | raise NotImplementedError 21 | 22 | def handle_message(self, msg): 23 | """Get message and append to our data structure""" 24 | self.data.append(msg) 25 | 26 | def _display(self, layout): 27 | """launch layouts display""" 28 | 29 | def on_set_current_module(self, module, filepath): 30 | """Hook called when a module starts to be analysed.""" 31 | print(".", end="") 32 | sys.stdout.flush() 33 | 34 | def on_close(self, stats, previous_stats): 35 | """Hook called when all modules finished analyzing.""" 36 | # print a new line when pylint is finished 37 | print("") 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{8, 9, 10}-pylint{215, 30}-pytest{7} 4 | py3{8, 9, 10, 11}-pylint{215, latest, main}-pytest{7, latest, main} 5 | py3{12}-pylint{latest, main}-pytest{7, latest, main} 6 | coverage 7 | qa 8 | skip_missing_interpreters = true 9 | 10 | [testenv] 11 | usedevelop = true 12 | deps = 13 | pylint215: pylint~=2.15.10 14 | pylint30: pylint~=3.0 15 | pylintlatest: pylint 16 | pylintmain: git+https://github.com/PyCQA/pylint.git@main#egg=pylint 17 | pylintmain: git+https://github.com/PyCQA/astroid.git@main#egg=astroid 18 | pytest7: pytest~=7.0.0 19 | pytestlatest: pytest 20 | pytestmain: git+https://github.com/pytest-dev/pytest.git@main#egg=pytest 21 | coverage 22 | commands = 23 | coverage run -m pytest {posargs} 24 | 25 | [testenv:coverage] 26 | depends = py3{7, 8, 9, 10, 11}-pylint{215, latest, main}-pytest{71, latest, main} 27 | commands = 28 | coverage combine 29 | coverage report 30 | coverage html -d htmlcov 31 | 32 | [testenv:qa] 33 | skip_install=true 34 | deps = 35 | black 36 | flake8 37 | isort 38 | commands = 39 | flake8 . 40 | black --check . 41 | isort --check-only --diff . 42 | 43 | [pytest] 44 | addopts = --pylint 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pytest-pylint 4 | ============= 5 | 6 | Plugin for py.test for doing pylint tests 7 | """ 8 | # pylint: disable=import-error 9 | from setuptools import setup 10 | 11 | with open("README.rst", encoding="utf-8") as f: 12 | LONG_DESCRIPTION = f.read() 13 | 14 | setup( 15 | name="pytest-pylint", 16 | description="pytest plugin to check source code with pylint", 17 | long_description=LONG_DESCRIPTION, 18 | license="MIT", 19 | version="0.21.0", 20 | author="Carson Gee", 21 | author_email="x@carsongee.com", 22 | url="https://github.com/carsongee/pytest-pylint", 23 | packages=["pytest_pylint"], 24 | entry_points={"pytest11": ["pylint = pytest_pylint.plugin"]}, 25 | python_requires=">=3.7", 26 | install_requires=[ 27 | "pytest>=7.0", 28 | "pylint>=2.15.0", 29 | "tomli>=1.1.0; python_version < '3.11'", 30 | ], 31 | setup_requires=["pytest-runner"], 32 | tests_require=["coverage", "flake8", "black", "isort"], 33 | classifiers=[ 34 | "Development Status :: 5 - Production/Stable", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | "Programming Language :: Python :: 3.10", 40 | "Programming Language :: Python :: 3.11", 41 | "Programming Language :: Python :: 3.12", 42 | ], 43 | ) 44 | -------------------------------------------------------------------------------- /pytest_pylint/tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Unit testing module for pytest-pylint util.py module 4 | """ 5 | from pytest_pylint.util import get_rel_path, should_include_file 6 | 7 | 8 | def test_get_rel_path(): 9 | """ 10 | Verify our relative path function. 11 | """ 12 | correct_rel_path = "How/Are/You/blah.py" 13 | path = "/Hi/How/Are/You/blah.py" 14 | parent_path = "/Hi/" 15 | assert get_rel_path(path, parent_path) == correct_rel_path 16 | 17 | parent_path = "/Hi" 18 | assert get_rel_path(path, parent_path) == correct_rel_path 19 | 20 | 21 | def test_should_include_path(): 22 | """ 23 | Files should only be included in the list if none of the directories on 24 | it's path, of the filename, match an entry in the ignore list. 25 | """ 26 | ignore_list = ["first", "second", "third", "part", "base.py"] 27 | # Default includes. 28 | assert should_include_file("random", ignore_list) is True 29 | assert should_include_file("random/filename", ignore_list) is True 30 | assert should_include_file("random/other/filename", ignore_list) is True 31 | # Basic ignore matches. 32 | assert should_include_file("first/filename", ignore_list) is False 33 | assert should_include_file("random/base.py", ignore_list) is False 34 | # Part on paths. 35 | assert should_include_file("part/second/filename.py", ignore_list) is False 36 | assert should_include_file("random/part/filename.py", ignore_list) is False 37 | assert should_include_file("random/second/part.py", ignore_list) is False 38 | # Part as substring on paths. 39 | assert should_include_file("part_it/other/filename.py", ignore_list) is True 40 | assert should_include_file("random/part_it/filename.py", ignore_list) is True 41 | assert should_include_file("random/other/part_it.py", ignore_list) is True 42 | 43 | 44 | def test_pylint_ignore_patterns(): 45 | """Test if the ignore-patterns is working""" 46 | ignore_patterns = ["first.*", ".*second", "^third.*fourth$", "part", "base.py"] 47 | 48 | # Default includes 49 | assert should_include_file("random", [], ignore_patterns) is True 50 | assert should_include_file("random/filename", [], ignore_patterns) is True 51 | assert should_include_file("random/other/filename", [], ignore_patterns) is True 52 | 53 | # Pattern matches 54 | assert should_include_file("first1", [], ignore_patterns) is False 55 | assert should_include_file("first", [], ignore_patterns) is False 56 | assert should_include_file("_second", [], ignore_patterns) is False 57 | assert should_include_file("second_", [], ignore_patterns) is False 58 | assert should_include_file("second_", [], ignore_patterns) is False 59 | assert should_include_file("third fourth", [], ignore_patterns) is False 60 | assert should_include_file("_third fourth_", [], ignore_patterns) is True 61 | assert should_include_file("part", [], ignore_patterns) is False 62 | assert should_include_file("1part2", [], ignore_patterns) is True 63 | assert should_include_file("base.py", [], ignore_patterns) is False 64 | -------------------------------------------------------------------------------- /DEVELOPMENT.rst: -------------------------------------------------------------------------------- 1 | pytest pylint 2 | ------------- 3 | 4 | How it works 5 | ============ 6 | Helpers for running pylint with py.test and have configurable rule 7 | types (i.e. Convention, Warn, and Error) fail the 8 | build. You can also specify a pylintrc file. 9 | 10 | How it works 11 | 12 | We have a thin plugin wrapper that is installed through setup.py hooks as `pylint`. 13 | This wrapper uses pytest_addoption and pytest_configure to decide to configure and 14 | register the real plugin PylintPlygin 15 | 16 | Once it is registered in `pytest_configure`, the hooks already executed 17 | by previous plugins will run. For instance, in case PylintPlugin had 18 | `pytest_addoption` implemented, which runs before `pytest_configure` 19 | in the hook cycle, it would be executed once PylintPlugin got registered. 20 | 21 | PylintPlugin uses the `pytest_collect_file` hook which is called with every 22 | file available in the test target dir. This hook collects all the file 23 | pylint should run on, in this case files with extension ".py". 24 | 25 | `pytest_collect_file` hook returns a collection of Node, or None. In 26 | py.test context, Node being a base class that defines py.test Collection 27 | Tree. 28 | 29 | A Node can be a subclass of Collector, which has children, or an Item, which 30 | is a leaf node. 31 | 32 | A practical example would be, a Python test file (Collector), can have multiple 33 | test functions (multiple Items) 34 | 35 | For this plugin, the relatioship of File to Item is one to one, one 36 | file represents one pylint result. 37 | 38 | From that, there are two important classes: PyLintFile, and PyLintItem. 39 | 40 | PyLintFile represents a python file, extension ".py", that was 41 | collected based on target directory as mentioned previously. 42 | 43 | PyLintItem represents one file which pylint was ran or will run. 44 | 45 | Back to PylintPlugin, `pytest_collection_finish` hook will run after the 46 | collection phase where pylint will be ran on the collected files. 47 | 48 | Based on the ProgrammaticReporter, the result is stored in a dictionary 49 | with the file relative path of the file being the key, and a list of 50 | errors related to the file. 51 | 52 | All PylintFile returned during `pytest_collect_file`, returns an one 53 | element list of PyLintItem. The Item implements runtest method which will 54 | get the pylint messages per file and expose to the user. 55 | 56 | Development Environment 57 | ======================= 58 | 59 | Suggestion 1 60 | ~~~~~~~~~~~~ 61 | Use `pyenv `_, and install all the versions supported by the plugin. 62 | Double-check on `tox.ini `_. 63 | 64 | .. code-block:: shell 65 | 66 | pyenv install 3.7.7 67 | 68 | pyenv install 3.8.2 69 | 70 | pyenv install 3.9.13 71 | 72 | pyenv install 3.10.6 73 | 74 | 75 | Set the installed versions as global, that will allow tox to find all of them. 76 | 77 | .. code-block:: shell 78 | 79 | pyenv global 3.10.6 3.9.13 3.8.2 3.7.7 80 | 81 | Create virtualenv, install dependencies, run tests, and tox: 82 | 83 | .. code-block:: shell 84 | 85 | python3.10 -m venv .pytest_pylint 86 | 87 | source .pytest_pylint/bin/activate 88 | 89 | pip install --upgrade setuptools pip tox 90 | 91 | python setup.py install 92 | 93 | python setup.py test 94 | 95 | tox 96 | 97 | The development environment is complete. 98 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytest pylint 2 | ------------- 3 | .. image:: https://github.com/carsongee/pytest-pylint/actions/workflows/tests.yml/badge.svg 4 | :target: https://github.com/carsongee/pytest-pylint/actions/workflows/tests.yml 5 | .. image:: https://img.shields.io/coveralls/carsongee/pytest-pylint.svg 6 | :target: https://coveralls.io/r/carsongee/pytest-pylint 7 | .. image:: https://img.shields.io/pypi/v/pytest-pylint.svg 8 | :target: https://pypi.python.org/pypi/pytest-pylint 9 | .. image:: https://anaconda.org/conda-forge/pytest-pylint/badges/version.svg 10 | :target: https://anaconda.org/conda-forge/pytest-pylint 11 | .. image:: https://anaconda.org/conda-forge/pytest-pylint/badges/downloads.svg 12 | :target: https://anaconda.org/conda-forge/pytest-pylint 13 | .. image:: https://img.shields.io/pypi/l/pytest-pylint.svg 14 | :target: https://pypi.python.org/pypi/pytest-pylint 15 | 16 | Run pylint with pytest and have configurable rule types 17 | (i.e. Convention, Warn, and Error) fail the build. You can also 18 | specify a pylintrc file. 19 | 20 | Sample Usage 21 | ============ 22 | .. code-block:: shell 23 | 24 | py.test --pylint 25 | 26 | would be the most simple usage and would run pylint for all error messages. 27 | 28 | .. code-block:: shell 29 | 30 | py.test --pylint --pylint-rcfile=/my/pyrc --pylint-error-types=EF --pylint-jobs=4 31 | 32 | This would use the pylintrc file at /my/pyrc, only error on pylint 33 | Errors and Failures, and use 4 cores for running pylint. 34 | 35 | You can restrict your test run to only perform pylint checks and not any other 36 | tests by typing: 37 | 38 | .. code-block:: shell 39 | 40 | py.test --pylint -m pylint 41 | 42 | Acknowledgements 43 | ================ 44 | 45 | This code is heavily based on 46 | `pytest-flakes `__ 47 | 48 | Development 49 | =========== 50 | 51 | If you want to help development, there is 52 | `overview documentation `_ 53 | 54 | Releases 55 | ======== 56 | 57 | 0.21.0 58 | ~~~~~~ 59 | - Dropped support for pytest < 7.0 in preparation for pytest 8.0 (should work with it when it comes out) 60 | - Dropped support for pylint < 2.15 to work better with Python 3.11 and drop backwards compatibility code 61 | - Use baked in TOML support with fallback to newer tomli library thanks to `mgorny `__ 62 | 63 | 64 | 0.20.0 65 | ~~~~~~ 66 | - Corrected issues introduced by deprecations in pylint 67 | - Added support for Python 3.12 and dropped support for Python 3.7 68 | - Last version that will support pytest < 7 and pylint < 2.6 69 | 70 | 0.19.0 71 | ~~~~~~ 72 | 73 | - Switched to GitHub Actions for CI thanks to `michael-k `__ 74 | - Switched to using smart PyLint RC discovery thanks to `bennyrowland `__ 75 | - Correcting rootdir/rootpath issues in pytest >7.x 76 | - Deprecated support for Python <3.7 77 | 78 | 79 | 0.18.0 80 | ~~~~~~ 81 | 82 | - Added support for creating missing folders when using ``--pylint-output-file`` 83 | - Now when pylint's ``ignore_patterns`` is blank, we don't ignore all files 84 | - Added cache invalidation when your pylintrc changes 85 | - Verified support for latest pytest and Python 3.9 86 | - Corrected badly named nodes (duplicated path) thanks to `yanqd0 `__ 87 | - Added tests to source distribution thanks to `sbraz `__ 88 | 89 | 90 | 0.17.0 91 | ~~~~~~ 92 | 93 | - Added support for latest pylint API >=2.5.1 94 | 95 | 96 | 0.16.1 97 | ~~~~~~ 98 | 99 | - Corrected documentation and correctly pinned dependencies properly 100 | 101 | 0.16.0 102 | ~~~~~~ 103 | 104 | - Switched to new ``from_parent`` API and added development documentation `dineshtrivedi `_ 105 | - Added support for toml based configuration of pylint thanks to `michael-k `_ 106 | 107 | 108 | 0.15.1 109 | ~~~~~~ 110 | 111 | - Made `--no-pylint` functional again 112 | 113 | 0.15.0 114 | ~~~~~~ 115 | 116 | - Added support for Python 3.8 thanks to `michael-k `_ 117 | - Implemented option to output Pylint results to a reports file thanks to `jose-lpa `_ 118 | - Refactored into simpler plugin structure 119 | 120 | 121 | 0.14.1 122 | ~~~~~~ 123 | 124 | - Corrected pytest-pylint to properly support ``-p no:cacheprovider`` 125 | thanks to `yanqd0 `__ 126 | 127 | 0.14.0 128 | ~~~~~~ 129 | 130 | - Added support for Pylint's ignore-patterns for regex based ignores 131 | thanks to `khokhlin `__ 132 | - pytest-pylint now caches successful pylint checks to speedup test 133 | reruns when files haven't changed thanks to `yanqd0 134 | `__ 135 | 136 | 0.13.0 137 | ~~~~~~ 138 | 139 | - Python 3.7 compatibility verified 140 | - Ignore paths no longer match partial names thanks to `heoga 141 | `__ 142 | 143 | 0.12.3 144 | ~~~~~~ 145 | 146 | - `jamur2 `__ corrected issue where file 147 | paths where not being output properly on lint failures. 148 | 149 | 0.12.2 150 | ~~~~~~ 151 | 152 | - Resolved issue where failing files weren't reported thanks to reports from 153 | `skirpichev `__ and `jamur2 `__ 154 | 155 | 156 | 0.12.1 157 | ~~~~~~ 158 | 159 | - Corrected a bug preventing this plugin from working with py.test >= 3.7.0. 160 | 161 | 0.12.0 162 | ~~~~~~ 163 | 164 | - `jwkvam `__ added progress output during linting. 165 | 166 | 0.11.0 167 | ~~~~~~ 168 | 169 | - Added option ``--no-pylint`` to override ``--pylint`` for cases when 170 | it's turned on by default. 171 | 172 | 0.10.0 173 | ~~~~~~ 174 | 175 | - `jwkvam `__ provided support for pylint 2.0 176 | 177 | 0.9.0 178 | ~~~~~ 179 | 180 | - `noisecapella `__ added an option to 181 | run pylint with multiple processes 182 | 183 | 0.8.0 184 | ~~~~~ 185 | 186 | - `bdrung `__ corrected inconsistent returns in a function 187 | - Dropped Python 3.3 support 188 | 189 | 0.7.1 190 | ~~~~~ 191 | 192 | - Corrected path issue reported by `Kargathia `_ 193 | 194 | 0.7.0 195 | ~~~~~ 196 | 197 | - Linting is performed before tests which enables code duplication 198 | checks to work along with a performance boost, thanks to @heoga 199 | -------------------------------------------------------------------------------- /pytest_pylint/tests/test_pytest_pylint.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Unit testing module for pytest-pylint plugin 4 | """ 5 | import pathlib 6 | import re 7 | from textwrap import dedent 8 | from unittest import mock 9 | 10 | import pylint.config 11 | import pytest 12 | 13 | pytest_plugins = ("pytester",) # pylint: disable=invalid-name 14 | 15 | 16 | def test_basic(testdir): 17 | """Verify basic pylint checks""" 18 | testdir.makepyfile("import sys") 19 | result = testdir.runpytest("--pylint") 20 | assert "Missing module docstring" in result.stdout.str() 21 | assert "Unused import sys" in result.stdout.str() 22 | assert "Final newline missing" in result.stdout.str() 23 | assert "passed, " not in result.stdout.str() 24 | assert "1 failed" in result.stdout.str() 25 | assert "Linting files" in result.stdout.str() 26 | 27 | 28 | def test_nodeid(testdir): 29 | """Verify our nodeid adds a suffix""" 30 | testdir.makepyfile(app="import sys") 31 | result = testdir.runpytest("--pylint", "--collectonly", "--verbose") 32 | for expected in "", "": 33 | assert expected in result.stdout.str() 34 | 35 | 36 | def test_nodeid_no_dupepath(testdir): 37 | """Verify we don't duplicate the node path in our node id.""" 38 | testdir.makepyfile(app="import sys") 39 | result = testdir.runpytest("--pylint", "--verbose") 40 | assert re.search( 41 | r"^FAILED\s+app\.py::PYLINT$", result.stdout.str(), flags=re.MULTILINE 42 | ) 43 | 44 | 45 | def test_subdirectories(testdir): 46 | """Verify pylint checks files in subdirectories""" 47 | subdir = testdir.mkpydir("mymodule") 48 | testfile = subdir.join("test_file.py") 49 | testfile.write("import sys") 50 | result = testdir.runpytest("--pylint") 51 | assert "[pylint] mymodule/test_file.py" in result.stdout.str() 52 | assert "Missing module docstring" in result.stdout.str() 53 | assert "Unused import sys" in result.stdout.str() 54 | assert "Final newline missing" in result.stdout.str() 55 | assert "1 failed" in result.stdout.str() 56 | assert "Linting files" in result.stdout.str() 57 | 58 | 59 | def test_disable(testdir): 60 | """Verify basic pylint checks""" 61 | testdir.makepyfile("import sys") 62 | result = testdir.runpytest("--pylint --no-pylint") 63 | assert "Final newline missing" not in result.stdout.str() 64 | assert "Linting files" not in result.stdout.str() 65 | 66 | 67 | def test_error_control(testdir): 68 | """Verify that error types are configurable""" 69 | testdir.makepyfile("import sys") 70 | result = testdir.runpytest("--pylint", "--pylint-error-types=EF") 71 | assert "1 passed" in result.stdout.str() 72 | 73 | 74 | def test_pylintrc_file(testdir): 75 | """Verify that a specified pylint rc file will work.""" 76 | rcfile = testdir.makefile( 77 | ".rc", 78 | """ 79 | [FORMAT] 80 | 81 | max-line-length=3 82 | """, 83 | ) 84 | testdir.makepyfile("import sys") 85 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 86 | assert "Line too long (10/3)" in result.stdout.str() 87 | 88 | 89 | def test_pylintrc_file_toml(testdir): 90 | """Verify that pyproject.toml can be used as a pylint rc file.""" 91 | rcfile = testdir.makefile( 92 | ".toml", 93 | pylint=""" 94 | [tool.pylint.FORMAT] 95 | max-line-length = "3" 96 | """, 97 | ) 98 | testdir.makepyfile("import sys") 99 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 100 | # Parsing changed from integer to string in pylint >=2.5. Once 101 | # support is dropped <2.5 this is removable 102 | if "should be of type int" in result.stdout.str(): 103 | rcfile = testdir.makefile( 104 | ".toml", 105 | pylint=""" 106 | [tool.pylint.FORMAT] 107 | max-line-length = 3 108 | """, 109 | ) 110 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 111 | 112 | assert "Line too long (10/3)" in result.stdout.str() 113 | 114 | 115 | def test_pylintrc_file_pyproject_toml(testdir): 116 | """Verify that pyproject.toml can be auto-detected as a pylint rc file.""" 117 | # pylint only auto-detects pyproject.toml from 2.5 onwards 118 | if not hasattr(pylint.config, "find_default_config_files"): 119 | return 120 | testdir.makefile( 121 | ".toml", 122 | pyproject=""" 123 | [tool.pylint.FORMAT] 124 | max-line-length = "3" 125 | """, 126 | ) 127 | testdir.makepyfile("import sys") 128 | result = testdir.runpytest("--pylint") 129 | 130 | assert "Line too long (10/3)" in result.stdout.str() 131 | 132 | 133 | def test_pylintrc_file_beside_ini(testdir): 134 | """ 135 | Verify that a specified pylint rc file will work when placed into pytest 136 | ini dir. 137 | """ 138 | non_cwd_dir = testdir.mkdir("non_cwd_dir") 139 | 140 | rcfile = non_cwd_dir.join("foo.rc") 141 | rcfile.write( 142 | dedent( 143 | """ 144 | [FORMAT] 145 | 146 | max-line-length=3 147 | """ 148 | ) 149 | ) 150 | inifile = non_cwd_dir.join("foo.ini") 151 | inifile.write( 152 | dedent( 153 | f""" 154 | [pytest] 155 | addopts = --pylint --pylint-rcfile={rcfile.strpath} 156 | """ 157 | ) 158 | ) 159 | # Per https://github.com/pytest-dev/pytest/pull/8537/ the rootdir 160 | # is now wherever the ini file is, so we need to make sure our 161 | # Python file is the right directory. 162 | pyfile_base = testdir.makepyfile("import sys") 163 | pyfile = non_cwd_dir / pyfile_base.basename 164 | pyfile_base.rename(pyfile) 165 | 166 | result = testdir.runpytest(pyfile.strpath) 167 | assert "Line too long (10/3)" not in result.stdout.str() 168 | 169 | result = testdir.runpytest("-c", inifile.strpath, pyfile.strpath) 170 | assert "Line too long (10/3)" in result.stdout.str() 171 | 172 | 173 | @pytest.mark.parametrize("rcformat", ("ini", "toml", "simple_toml")) 174 | @pytest.mark.parametrize("sectionname", ("main", "master")) 175 | def test_pylintrc_ignore(testdir, rcformat, sectionname): 176 | """Verify that a pylintrc file with ignores will work.""" 177 | if rcformat == "toml": 178 | rcfile = testdir.makefile( 179 | ".toml", 180 | f""" 181 | [tool.pylint.{sectionname}] 182 | ignore = ["test_pylintrc_ignore.py", "foo.py"] 183 | """, 184 | ) 185 | elif rcformat == "simple_toml": 186 | rcfile = testdir.makefile( 187 | ".toml", 188 | f""" 189 | [tool.pylint.{sectionname.upper()}] 190 | ignore = "test_pylintrc_ignore.py,foo.py" 191 | """, 192 | ) 193 | else: 194 | rcfile = testdir.makefile( 195 | ".rc", 196 | f""" 197 | [{sectionname.upper()}] 198 | 199 | ignore = test_pylintrc_ignore.py 200 | """, 201 | ) 202 | testdir.makepyfile("import sys") 203 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 204 | assert "collected 0 items" in result.stdout.str() 205 | 206 | 207 | @pytest.mark.parametrize("rcformat", ("ini", "toml")) 208 | def test_pylintrc_msg_template(testdir, rcformat): 209 | """Verify that msg-template from pylintrc file is handled.""" 210 | if rcformat == "toml": 211 | rcfile = testdir.makefile( 212 | ".toml", 213 | """ 214 | [tool.pylint.REPORTS] 215 | msg-template = "start {msg_id} end" 216 | """, 217 | ) 218 | else: 219 | rcfile = testdir.makefile( 220 | ".rc", 221 | """ 222 | [REPORTS] 223 | 224 | msg-template=start {msg_id} end 225 | """, 226 | ) 227 | testdir.makepyfile("import sys") 228 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 229 | assert "start W0611 end" in result.stdout.str() 230 | 231 | 232 | def test_multiple_jobs(testdir): 233 | """ 234 | Assert that the jobs argument is passed through to pylint if provided 235 | """ 236 | testdir.makepyfile("import sys") 237 | with mock.patch("pytest_pylint.plugin.lint.Run") as run_mock: 238 | jobs = 0 239 | testdir.runpytest("--pylint", f"--pylint-jobs={jobs}") 240 | assert run_mock.call_count == 1 241 | assert run_mock.call_args[0][0][-2:] == ["-j", str(jobs)] 242 | 243 | 244 | def test_no_multiple_jobs(testdir): 245 | """ 246 | If no jobs argument is specified it should not appear in pylint arguments 247 | """ 248 | testdir.makepyfile("import sys") 249 | with mock.patch("pytest_pylint.plugin.lint.Run") as run_mock: 250 | testdir.runpytest("--pylint") 251 | assert run_mock.call_count == 1 252 | assert "-j" not in run_mock.call_args[0][0] 253 | 254 | 255 | def test_skip_checked_files(testdir): 256 | """ 257 | Test a file twice which can pass pylint. 258 | The 2nd time should be skipped. 259 | """ 260 | testdir.makepyfile( 261 | "#!/usr/bin/env python", 262 | '"""A hello world script."""', 263 | "", 264 | "from __future__ import print_function", 265 | "", 266 | 'print("Hello world!") # pylint: disable=missing-final-newline', 267 | ) 268 | # The 1st time should be passed 269 | result = testdir.runpytest("--pylint") 270 | assert "1 passed" in result.stdout.str() 271 | 272 | # The 2nd time should be skipped 273 | result = testdir.runpytest("--pylint") 274 | assert "1 skipped" in result.stdout.str() 275 | 276 | # Always be passed when cacheprovider disabled 277 | result = testdir.runpytest("--pylint", "-p", "no:cacheprovider") 278 | assert "1 passed" in result.stdout.str() 279 | 280 | 281 | def test_invalidate_cache_when_config_changes(testdir): 282 | """If pylintrc changes, no cache should apply.""" 283 | rcfile = testdir.makefile( 284 | ".rc", "[MESSAGES CONTROL]", "disable=missing-final-newline" 285 | ) 286 | testdir.makepyfile('"""hi."""') 287 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 288 | assert "1 passed" in result.stdout.str() 289 | 290 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 291 | assert "1 skipped" in result.stdout.str() 292 | 293 | # Change RC file entirely 294 | alt_rcfile = testdir.makefile( 295 | ".rc", alt="[MESSAGES CONTROL]\ndisable=unbalanced-tuple-unpacking" 296 | ) 297 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={alt_rcfile.strpath}") 298 | assert "1 failed" in result.stdout.str() 299 | 300 | # Change contents of RC file 301 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 302 | assert "1 passed" in result.stdout.str() 303 | 304 | with open(rcfile, "w", encoding="utf-8"): 305 | pass 306 | 307 | result = testdir.runpytest("--pylint", f"--pylint-rcfile={rcfile.strpath}") 308 | assert "1 failed" in result.stdout.str() 309 | 310 | 311 | def test_output_file(testdir): 312 | """Verify pylint report output""" 313 | testdir.makepyfile("import sys") 314 | testdir.runpytest("--pylint", "--pylint-output-file=pylint.report") 315 | output_file = pathlib.Path(testdir.tmpdir.strpath) / "pylint.report" 316 | assert output_file.is_file() 317 | 318 | with open(output_file, "r", encoding="utf-8") as _file: 319 | report = _file.read() 320 | 321 | assert ( 322 | "test_output_file.py:1: [C0304(missing-final-newline), ] Final " 323 | "newline missing" 324 | ) in report 325 | 326 | assert ( 327 | "test_output_file.py:1: [C0111(missing-docstring), ] Missing " 328 | "module docstring" 329 | ) in report or ( 330 | "test_output_file.py:1: [C0114(missing-module-docstring), ] Missing " 331 | "module docstring" 332 | ) in report 333 | 334 | assert ( 335 | "test_output_file.py:1: [W0611(unused-import), ] Unused import sys" 336 | ) in report 337 | 338 | 339 | def test_output_file_makes_dirs(testdir): 340 | """Verify output works with folders properly.""" 341 | testdir.makepyfile("import sys") 342 | output_path = pathlib.Path("reports", "pylint.report") 343 | testdir.runpytest("--pylint", f"--pylint-output-file={output_path}") 344 | output_file = pathlib.Path(testdir.tmpdir.strpath) / output_path 345 | assert output_file.is_file() 346 | # Run again to make sure we don't crash trying to make a dir that exists 347 | testdir.runpytest("--pylint", f"--pylint-output-file={output_path}") 348 | 349 | 350 | @pytest.mark.parametrize( 351 | "arg_opt_name, arg_opt_value", 352 | [("ignore", "test_cmd_line_ignore.py"), ("ignore-patterns", ".+_ignore.py")], 353 | ids=["ignore", "ignore-patterns"], 354 | ) 355 | def test_cmd_line_ignore(testdir, arg_opt_name, arg_opt_value): 356 | """Verify that cmd line args ignores will work.""" 357 | testdir.makepyfile(test_cmd_line_ignore="import sys") 358 | result = testdir.runpytest("--pylint", f"--pylint-{arg_opt_name}={arg_opt_value}") 359 | assert "collected 0 items" in result.stdout.str() 360 | assert "Unused import sys" not in result.stdout.str() 361 | 362 | 363 | @pytest.mark.parametrize( 364 | "arg_opt_name, arg_opt_value", 365 | [("ignore", "test_cmd_line_ignore_pri_arg.py"), ("ignore-patterns", ".*arg.py$")], 366 | ids=["ignore", "ignore-patterns"], 367 | ) 368 | @pytest.mark.parametrize("sectionname", ("main", "master")) 369 | def test_cmd_line_ignore_pri(testdir, arg_opt_name, arg_opt_value, sectionname): 370 | """ 371 | Verify that command line ignores and patterns take priority over 372 | rcfile ignores. 373 | """ 374 | file_ignore = "test_cmd_line_ignore_pri_file.py" 375 | cmd_arg_ignore = "test_cmd_line_ignore_pri_arg.py" 376 | cmd_line_ignore = arg_opt_value 377 | 378 | rcfile = testdir.makefile( 379 | ".rc", 380 | f""" 381 | [{sectionname.upper()}] 382 | 383 | {arg_opt_name} = {file_ignore},foo 384 | """, 385 | ) 386 | testdir.makepyfile(**{file_ignore: "import sys", cmd_arg_ignore: "import os"}) 387 | result = testdir.runpytest( 388 | "--pylint", 389 | f"--pylint-rcfile={rcfile.strpath}", 390 | f"--pylint-{arg_opt_name}={cmd_line_ignore}", 391 | "-s", 392 | ) 393 | 394 | assert "collected 1 item" in result.stdout.str() 395 | assert "Unused import sys" in result.stdout.str() 396 | -------------------------------------------------------------------------------- /pytest_pylint/plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pytest plugins. Both pylint wrapper and PylintPlugin 4 | """ 5 | 6 | 7 | import sys 8 | from collections import defaultdict 9 | from configparser import ConfigParser, NoOptionError, NoSectionError 10 | from os import getcwd, makedirs, sep 11 | from os.path import dirname, exists, getmtime, join 12 | from pathlib import Path 13 | 14 | import pytest 15 | from pylint import config as pylint_config 16 | from pylint import lint 17 | 18 | from .pylint_util import ProgrammaticReporter 19 | from .util import PyLintException, get_rel_path, should_include_file 20 | 21 | if sys.version_info >= (3, 11): 22 | import tomllib 23 | else: 24 | # pylint: disable=import-error 25 | import tomli as tomllib 26 | 27 | HISTKEY = "pylint/mtimes" 28 | PYLINT_CONFIG_CACHE_KEY = "pylintrc" 29 | FILL_CHARS = 80 30 | MARKER = "pylint" 31 | 32 | 33 | def pytest_addoption(parser): 34 | """Add all our command line options""" 35 | group = parser.getgroup("pylint") 36 | group.addoption( 37 | "--pylint", action="store_true", default=False, help="run pylint on all" 38 | ) 39 | group.addoption( 40 | "--no-pylint", 41 | action="store_true", 42 | default=False, 43 | help="disable running pylint ", 44 | ) 45 | 46 | group.addoption( 47 | "--pylint-rcfile", default=None, help="Location of RC file if not pylintrc" 48 | ) 49 | group.addoption( 50 | "--pylint-error-types", 51 | default="CRWEF", 52 | help="The types of pylint errors to consider failures by letter" 53 | ", default is all of them (CRWEF).", 54 | ) 55 | group.addoption( 56 | "--pylint-jobs", 57 | default=None, 58 | help="Specify number of processes to use for pylint", 59 | ) 60 | group.addoption( 61 | "--pylint-output-file", 62 | default=None, 63 | help="Path to a file where Pylint report will be printed to.", 64 | ) 65 | group.addoption( 66 | "--pylint-ignore", default=None, help="Files/directories that will be ignored" 67 | ) 68 | group.addoption( 69 | "--pylint-ignore-patterns", 70 | default=None, 71 | help="Files/directories patterns that will be ignored", 72 | ) 73 | 74 | 75 | def pytest_configure(config): 76 | """ 77 | Add plugin class. 78 | 79 | :param _pytest.config.Config config: pytest config object 80 | """ 81 | config.addinivalue_line("markers", f"{MARKER}: Tests which run pylint.") 82 | if config.option.pylint and not config.option.no_pylint: 83 | pylint_plugin = PylintPlugin(config) 84 | config.pluginmanager.register(pylint_plugin) 85 | 86 | 87 | class PylintPlugin: 88 | """ 89 | The core plugin for pylint 90 | """ 91 | 92 | # pylint: disable=too-many-instance-attributes 93 | def __init__(self, config): 94 | if hasattr(config, "cache"): 95 | self.mtimes = config.cache.get(HISTKEY, {}) 96 | else: 97 | self.mtimes = {} 98 | 99 | self.pylint_files = set() 100 | self.pylint_messages = defaultdict(list) 101 | self.pylint_config = None 102 | self.pylintrc_file = None 103 | self.pylint_ignore = [] 104 | self.pylint_ignore_patterns = [] 105 | self.pylint_msg_template = None 106 | 107 | def pytest_configure(self, config): 108 | """Configure pytest after it is already enabled""" 109 | 110 | # Find pylintrc to check ignore list 111 | if config.option.pylint_rcfile: 112 | pylintrc_file = config.option.pylint_rcfile 113 | else: 114 | pylintrc_file = next(pylint_config.find_default_config_files(), None) 115 | 116 | if pylintrc_file and not exists(pylintrc_file): 117 | # The directory of pytest.ini got a chance 118 | pylintrc_file = join(dirname(str(config.inifile)), pylintrc_file) 119 | 120 | # Try getting ignores from pylintrc since we use pytest 121 | # collection methods and not pylint's internal mechanism 122 | if pylintrc_file and exists(pylintrc_file): 123 | self.pylintrc_file = pylintrc_file 124 | 125 | # Check if pylint config has a different filename or date 126 | # and invalidate the cache if it has changed. 127 | pylint_mtime = getmtime(pylintrc_file) 128 | cache_key = PYLINT_CONFIG_CACHE_KEY + ( 129 | pylintrc_file.name if isinstance(pylintrc_file, Path) else pylintrc_file 130 | ) 131 | cache_value = self.mtimes.get(cache_key) 132 | if cache_value is None or cache_value < pylint_mtime: 133 | self.mtimes = {} 134 | self.mtimes[cache_key] = pylint_mtime 135 | 136 | if ( 137 | (pylintrc_file.suffix == ".toml") 138 | if isinstance(pylintrc_file, Path) 139 | else pylintrc_file.endswith(".toml") 140 | ): 141 | self._load_pyproject_toml(pylintrc_file) 142 | else: 143 | self._load_rc_file(pylintrc_file) 144 | 145 | # Command line arguments take presedence over rcfile ones if set 146 | if config.option.pylint_ignore is not None: 147 | self.pylint_ignore = config.option.pylint_ignore.split(",") 148 | if config.option.pylint_ignore_patterns is not None: 149 | self.pylint_ignore_patterns = config.option.pylint_ignore_patterns.split( 150 | "," 151 | ) 152 | 153 | def _load_rc_file(self, pylintrc_file): 154 | self.pylint_config = ConfigParser() 155 | self.pylint_config.read(pylintrc_file) 156 | 157 | try: 158 | ignore_string = self.pylint_config.get("MAIN", "ignore") 159 | if ignore_string: 160 | self.pylint_ignore = ignore_string.split(",") 161 | except (NoSectionError, NoOptionError): 162 | try: 163 | ignore_string = self.pylint_config.get("MASTER", "ignore") 164 | if ignore_string: 165 | self.pylint_ignore = ignore_string.split(",") 166 | except (NoSectionError, NoOptionError): 167 | pass 168 | 169 | try: 170 | ignore_patterns = self.pylint_config.get("MAIN", "ignore-patterns") 171 | if ignore_patterns: 172 | self.pylint_ignore_patterns = ignore_patterns.split(",") 173 | except (NoSectionError, NoOptionError): 174 | try: 175 | ignore_patterns = self.pylint_config.get("MASTER", "ignore-patterns") 176 | if ignore_patterns: 177 | self.pylint_ignore_patterns = ignore_patterns.split(",") 178 | except (NoSectionError, NoOptionError): 179 | pass 180 | 181 | try: 182 | self.pylint_msg_template = self.pylint_config.get("REPORTS", "msg-template") 183 | except (NoSectionError, NoOptionError): 184 | pass 185 | 186 | def _load_pyproject_toml(self, pylintrc_file): 187 | with open(pylintrc_file, "rb") as f_p: 188 | try: 189 | content = tomllib.load(f_p) 190 | except (TypeError, tomllib.TOMLDecodeError): 191 | return 192 | 193 | try: 194 | self.pylint_config = content["tool"]["pylint"] 195 | except KeyError: 196 | return 197 | 198 | main_section = {} 199 | reports_section = {} 200 | for key, value in self.pylint_config.items(): 201 | if not main_section and key.lower() in ("main", "master"): 202 | main_section = value 203 | elif not reports_section and key.lower() == "reports": 204 | reports_section = value 205 | 206 | ignore = main_section.get("ignore") 207 | if ignore: 208 | self.pylint_ignore = ( 209 | ignore.split(",") if isinstance(ignore, str) else ignore 210 | ) 211 | self.pylint_ignore_patterns = main_section.get("ignore-patterns") or [] 212 | self.pylint_msg_template = reports_section.get("msg-template") 213 | 214 | def pytest_sessionfinish(self, session): 215 | """ 216 | Save file mtimes to pytest cache. 217 | 218 | :param _pytest.main.Session session: the pytest session object 219 | """ 220 | if hasattr(session.config, "cache"): 221 | session.config.cache.set(HISTKEY, self.mtimes) 222 | 223 | def pytest_collect_file(self, file_path, parent): 224 | """Collect files on which pylint should run""" 225 | if file_path.suffix != ".py": 226 | return None 227 | 228 | rel_path = file_path.relative_to(parent.session.path) 229 | if should_include_file( 230 | str(rel_path), self.pylint_ignore, self.pylint_ignore_patterns 231 | ): 232 | item = PylintFile.from_parent(parent, path=file_path, plugin=self) 233 | else: 234 | return None 235 | 236 | # Check the cache if we should run it 237 | if not item.should_skip: 238 | self.pylint_files.add(rel_path) 239 | return item 240 | 241 | def pytest_collection_finish(self, session): 242 | """Lint collected files""" 243 | if not self.pylint_files: 244 | return 245 | 246 | jobs = session.config.option.pylint_jobs 247 | reporter = ProgrammaticReporter() 248 | 249 | # To try and bullet proof our paths, use our 250 | # relative paths to the resolved path of the pytest rootpath 251 | try: 252 | root_path = session.config.rootpath.resolve() 253 | except AttributeError: 254 | root_path = Path(session.config.rootdir.realpath()) 255 | 256 | args_list = [ 257 | str((root_path / file_path).relative_to(getcwd())) 258 | for file_path in self.pylint_files 259 | ] 260 | # Add any additional arguments to our pylint run 261 | if self.pylintrc_file: 262 | args_list.append(f"--rcfile={self.pylintrc_file}") 263 | if jobs is not None: 264 | args_list.append("-j") 265 | args_list.append(jobs) 266 | # These allow the user to override the pylint configuration's 267 | # ignore list 268 | if self.pylint_ignore: 269 | args_list.append(f"--ignore={','.join(self.pylint_ignore)}") 270 | if self.pylint_ignore_patterns: 271 | args_list.append( 272 | f"--ignore-patterns={','.join(self.pylint_ignore_patterns)}" 273 | ) 274 | print("-" * FILL_CHARS) 275 | print("Linting files") 276 | 277 | # Run pylint over the collected files. 278 | 279 | # Pylint has changed APIs, but we support both 280 | # pylint: disable=unexpected-keyword-arg 281 | try: 282 | # pylint >= 2.5.1 API 283 | result = lint.Run(args_list, reporter=reporter, exit=False) 284 | except TypeError: 285 | # pylint < 2.5.1 API 286 | result = lint.Run(args_list, reporter=reporter, do_exit=False) 287 | 288 | messages = result.linter.reporter.data 289 | # Stores the messages in a dictionary for lookup in tests. 290 | for message in messages: 291 | # Undo our mapping to resolved absolute paths to map 292 | # back to self.pylint_files 293 | relpath = message.abspath.replace(f"{root_path}{sep}", "") 294 | self.pylint_messages[relpath].append(message) 295 | print("-" * FILL_CHARS) 296 | 297 | 298 | class PylintFile(pytest.File): 299 | """File that pylint will run on.""" 300 | 301 | rel_path = None # : str 302 | plugin = None # : PylintPlugin 303 | should_skip = False # : bool 304 | mtime = None # : float 305 | 306 | @classmethod 307 | def from_parent(cls, parent, *, path, plugin, **kw): 308 | # pylint: disable=arguments-differ 309 | # We add the ``plugin`` kwarg to get plugin level information so the 310 | # signature differs 311 | _self = getattr(super(), "from_parent", cls)(parent, path=path, **kw) 312 | _self.plugin = plugin 313 | 314 | _self.rel_path = get_rel_path(str(path), str(parent.session.path)) 315 | _self.mtime = path.stat().st_mtime 316 | prev_mtime = _self.plugin.mtimes.get(_self.rel_path, 0) 317 | _self.should_skip = prev_mtime == _self.mtime 318 | 319 | return _self 320 | 321 | def collect(self): 322 | """Create a PyLintItem for the File.""" 323 | yield PyLintItem.from_parent(parent=self, name="PYLINT") 324 | 325 | 326 | class PyLintItem(pytest.Item): 327 | """pylint test running class.""" 328 | 329 | parent = None # : PylintFile 330 | plugin = None # : PylintPlugin 331 | 332 | def __init__(self, *args, **kw): 333 | super().__init__(*args, **kw) 334 | self.add_marker(MARKER) 335 | self.plugin = self.parent.plugin 336 | 337 | msg_format = self.plugin.pylint_msg_template 338 | if msg_format is None: 339 | self._msg_format = "{C}:{line:3d},{column:2d}: {msg} ({symbol})" 340 | else: 341 | self._msg_format = msg_format 342 | 343 | @classmethod 344 | def from_parent(cls, parent, **kw): 345 | return getattr(super(), "from_parent", cls)(parent, **kw) 346 | 347 | def setup(self): 348 | """Mark unchanged files as SKIPPED.""" 349 | if self.parent.should_skip: 350 | pytest.skip("file(s) previously passed pylint checks") 351 | 352 | def runtest(self): 353 | """Check the pylint messages to see if any errors were reported.""" 354 | pylint_output_file = self.config.option.pylint_output_file 355 | 356 | def _loop_errors(writer): 357 | reported_errors = [] 358 | for error in self.plugin.pylint_messages.get(self.parent.rel_path, []): 359 | if error.C in self.config.option.pylint_error_types: 360 | reported_errors.append(error.format(self._msg_format)) 361 | 362 | writer( 363 | f"{error.path}:{error.line}: [{error.msg_id}" 364 | f"({error.symbol}), {error.obj}] " 365 | f"{error.msg}\n" 366 | ) 367 | 368 | return reported_errors 369 | 370 | if pylint_output_file: 371 | output_dir = dirname(pylint_output_file) 372 | if output_dir: 373 | makedirs(output_dir, exist_ok=True) 374 | with open(pylint_output_file, "a", encoding="utf-8") as _file: 375 | reported_errors = _loop_errors(writer=_file.write) 376 | else: 377 | reported_errors = _loop_errors(writer=lambda *args, **kwargs: None) 378 | 379 | if reported_errors: 380 | raise PyLintException("\n".join(reported_errors)) 381 | 382 | # Update the cache if the item passed pylint. 383 | self.plugin.mtimes[self.parent.rel_path] = self.parent.mtime 384 | 385 | def repr_failure(self, excinfo, style=None): 386 | """Handle any test failures by checking that they were ours.""" 387 | # pylint: disable=arguments-differ 388 | if excinfo.errisinstance(PyLintException): 389 | return excinfo.value.args[0] 390 | return super().repr_failure(excinfo) 391 | 392 | def reportinfo(self): 393 | """Generate our test report""" 394 | # pylint: disable=no-member 395 | return self.path, None, f"[pylint] {self.parent.rel_path}" 396 | --------------------------------------------------------------------------------