├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README ├── README.md ├── VERSION ├── setup.cfg ├── setup.py ├── src └── pytest_pydocstyle.py └── tests └── test_pytest_pydocstyle.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - master 11 | - develop 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: 19 | - "3.9" 20 | - "3.10" 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v4 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: Setup 35 | run: make setup 36 | 37 | - name: Test 38 | run: make test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # pipenv 75 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 76 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 77 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 78 | # install all needed dependencies. 79 | #Pipfile.lock 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | .spyproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | # Mr Developer 95 | .mr.developer.cfg 96 | .project 97 | .pydevproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | .dmypy.json 105 | dmypy.json 106 | 107 | # Pyre type checker 108 | .pyre/ 109 | 110 | # End of https://www.gitignore.io/api/python 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 OMOTO Tsukasa 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include Makefile 3 | include README.md 4 | include VERSION 5 | recursive-include tests *.py 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | PACKAGE := $(shell \grep "name=" setup.py | \sed -e "s/ *name='\(.*\)',/\1/") 3 | VERSION := $(shell \cat VERSION) 4 | 5 | install: 6 | pip install . 7 | .PHONY: install 8 | 9 | uninstall: 10 | pip uninstall -y $(PACKAGE) 11 | .PHONY: uninstall 12 | 13 | update: clean uninstall install 14 | .PHONY: update 15 | 16 | clean: 17 | ${RM} -fr {.,src,tests}/*.egg-info {.,src,tests}/.cache {.,src,tests}/.pytest_cache {.,src,tests}/__pycache__ 18 | .PHONY: clean 19 | 20 | setup: 21 | pip install -e .[tests] 22 | .PHONY: setup 23 | 24 | test: 25 | pytest src tests 26 | .PHONY: test 27 | 28 | sdist: clean 29 | python setup.py sdist 30 | .PHONY: sdist 31 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-pydocstyle 2 | 3 | [![PyPI version](https://badge.fury.io/py/pytest-pydocstyle.svg)](https://pypi.org/project/pytest-pydocstyle/) 4 | 5 | [pytest](https://docs.pytest.org/en/latest/) plugin to run [pydocstyle](https://github.com/PyCQA/pydocstyle) 6 | 7 | ## Installation 8 | 9 | ```sh 10 | pip install pytest-pydocstyle 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```sh 16 | pytest --pydocstyle ... 17 | ``` 18 | 19 | For detail, please see `pytest -h` after installation. 20 | 21 | ## Configuration 22 | 23 | The behavior can be configured in the same style of pydocstyle. 24 | (cf. [Configuration — pytest documentation](https://docs.pytest.org/en/latest/customize.html) and [Configuration Files — pydocstyle documentation](http://www.pydocstyle.org/en/latest/usage.html#configuration-files)) 25 | 26 | For example, 27 | 28 | ``` 29 | [pydocstyle] 30 | convention = numpy 31 | add-ignore = D400,D403 32 | 33 | [tool:pytest] 34 | addopts = --pydocstyle 35 | ``` 36 | 37 | ## Licence 38 | 39 | The MIT License 40 | Copyright (c) 2019 OMOTO Tsukasa 41 | 42 | ## Acknowledgments 43 | 44 | - [abendebury/pytest-pep257](https://github.com/abendebury/pytest-pep257) 45 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.4.0 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max-line-length = 127 3 | 4 | [tool:pytest] 5 | addopts = --isort --pycodestyle 6 | norecursedirs = build dist *.egg-info .git 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | 5 | version = open('VERSION', 'rb').read().decode('utf-8').strip() 6 | long_description = open('README.md', 'rb').read().decode('utf-8') 7 | 8 | setup( 9 | name='pytest-pydocstyle', 10 | version=version, 11 | description='pytest plugin to run pydocstyle', 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/henry0312/pytest-pydocstyle', 15 | author='OMOTO Tsukasa', 16 | author_email='tsukasa@oomo.to', 17 | license='MIT', 18 | package_dir={'': 'src'}, 19 | py_modules=['pytest_pydocstyle'], 20 | python_requires='~=3.9', 21 | install_requires=[ 22 | 'pytest>=7.0', 23 | 'pydocstyle', 24 | ], 25 | extras_require={ 26 | 'tests': [ 27 | 'pytest-pycodestyle~=2.3', 28 | 'pytest-isort', 29 | ], 30 | }, 31 | # https://docs.pytest.org/en/latest/writing_plugins.html#making-your-plugin-installable-by-others 32 | entry_points={ 33 | 'pytest11': [ 34 | 'pydocstyle = pytest_pydocstyle', 35 | ] 36 | }, 37 | classifiers=[ 38 | 'Development Status :: 3 - Alpha', 39 | 'Intended Audience :: Developers', 40 | 'Topic :: Software Development :: Testing', 41 | 'License :: OSI Approved :: MIT License', 42 | 'Framework :: Pytest', 43 | 'Programming Language :: Python :: 3', 44 | 'Programming Language :: Python :: 3.9', 45 | 'Programming Language :: Python :: 3.10', 46 | 'Programming Language :: Python :: 3.11', 47 | 'Programming Language :: Python :: 3.12', 48 | 'Programming Language :: Python :: 3.13', 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /src/pytest_pydocstyle.py: -------------------------------------------------------------------------------- 1 | # https://docs.pytest.org/en/latest/writing_plugins.html 2 | # https://docs.pytest.org/en/latest/example/nonpython.html#yaml-plugin 3 | 4 | import contextlib 5 | import logging 6 | import pathlib 7 | import sys 8 | 9 | import pydocstyle 10 | import pytest 11 | 12 | 13 | def pytest_addoption(parser): 14 | group = parser.getgroup('pydocstyle') 15 | group.addoption('--pydocstyle', action='store_true', 16 | default=False, help='run pydocstyle') 17 | 18 | 19 | def pytest_configure(config): 20 | config.addinivalue_line('markers', 'pydocstyle: mark tests to be checked by pydocstyle.') 21 | 22 | 23 | # https://github.com/palantir/python-language-server/blob/0.30.0/pyls/plugins/pydocstyle_lint.py#L110 24 | # LICENSE: https://github.com/palantir/python-language-server/blob/0.30.0/LICENSE 25 | @contextlib.contextmanager 26 | def _patch_sys_argv(arguments): 27 | old_args = sys.argv 28 | 29 | # Preserve argv[0] since it's the executable 30 | sys.argv = old_args[0:1] + arguments 31 | 32 | try: 33 | yield 34 | finally: 35 | sys.argv = old_args 36 | 37 | 38 | def pytest_collect_file(file_path: pathlib.Path, path, parent): 39 | """Create a Collector for the given path, or None if not relevant. 40 | 41 | See: 42 | - https://docs.pytest.org/en/7.0.x/reference/reference.html#pytest.hookspec.pytest_collect_file 43 | """ 44 | config = parent.config 45 | if config.getoption('pydocstyle') and file_path.suffix == '.py': 46 | parser = pydocstyle.config.ConfigurationParser() 47 | args = [file_path.name] 48 | with _patch_sys_argv(args): 49 | parser.parse() 50 | for filename, *_ in parser.get_files_to_check(): 51 | return File.from_parent(parent=parent, path=file_path, config_parser=parser) 52 | 53 | 54 | class File(pytest.File): 55 | 56 | @classmethod 57 | def from_parent(cls, parent, path: pathlib.Path, config_parser: pydocstyle.config.ConfigurationParser): 58 | # https://github.com/pytest-dev/pytest/blob/3e4c14bfaa046bcb5b75903470accf83d93f01ce/src/_pytest/nodes.py#L624 59 | _file = super().from_parent(parent=parent, path=path) 60 | # store config parser of pydocstyle 61 | _file.config_parser = config_parser 62 | return _file 63 | 64 | def collect(self): 65 | # https://github.com/pytest-dev/pytest/blob/3e4c14bfaa046bcb5b75903470accf83d93f01ce/src/_pytest/nodes.py#L524 66 | yield Item.from_parent(parent=self, name=self.name, nodeid=self.nodeid) 67 | 68 | 69 | class Item(pytest.Item): 70 | CACHE_KEY = 'pydocstyle/mtimes' 71 | 72 | def __init__(self, name, parent, nodeid): 73 | # https://github.com/pytest-dev/pytest/blob/ee1950af7793624793ee297e5f48b49c8bdf2065/src/_pytest/nodes.py#L544 74 | super().__init__(name, parent=parent, nodeid=f"{nodeid}::PYDOCSTYLE") 75 | self.add_marker('pydocstyle') 76 | # load config parser of pydocstyle 77 | self.parser: pydocstyle.config.ConfigurationParser = self.parent.config_parser 78 | 79 | def setup(self): 80 | if not hasattr(self.config, 'cache'): 81 | return 82 | 83 | old_mtime = self.config.cache.get(self.CACHE_KEY, {}).get(str(self.fspath), -1) 84 | mtime = self.fspath.mtime() 85 | if old_mtime == mtime: 86 | pytest.skip('previously passed pydocstyle checks') 87 | 88 | def runtest(self): 89 | pydocstyle.utils.log.setLevel(logging.WARN) # TODO: follow that of pytest 90 | 91 | # https://github.com/PyCQA/pydocstyle/blob/4.0.1/src/pydocstyle/cli.py#L42-L45 92 | for filename, checked_codes, ignore_decorators, *_ in self.parser.get_files_to_check(): 93 | errors = [str(error) for error in pydocstyle.check((str(self.fspath),), select=checked_codes, 94 | ignore_decorators=ignore_decorators)] 95 | if errors: 96 | raise PyDocStyleError('\n'.join(errors)) 97 | elif hasattr(self.config, 'cache'): 98 | # update cache 99 | # http://pythonhosted.org/pytest-cache/api.html 100 | cache = self.config.cache.get(self.CACHE_KEY, {}) 101 | cache[str(self.fspath)] = self.fspath.mtime() 102 | self.config.cache.set(self.CACHE_KEY, cache) 103 | 104 | def repr_failure(self, excinfo): 105 | if excinfo.errisinstance(PyDocStyleError): 106 | return excinfo.value.args[0] 107 | else: 108 | return super().repr_failure(excinfo) 109 | 110 | def reportinfo(self): 111 | # https://github.com/pytest-dev/pytest/blob/4678cbeb913385f00cc21b79662459a8c9fafa87/_pytest/main.py#L550 112 | # https://github.com/pytest-dev/pytest/blob/4678cbeb913385f00cc21b79662459a8c9fafa87/_pytest/doctest.py#L149 113 | return self.fspath, None, 'pydocstyle-check' 114 | 115 | 116 | class PyDocStyleError(Exception): 117 | """custom exception for error reporting.""" 118 | -------------------------------------------------------------------------------- /tests/test_pytest_pydocstyle.py: -------------------------------------------------------------------------------- 1 | import pytest_pydocstyle 2 | 3 | # https://docs.pytest.org/en/5.2.2/writing_plugins.html#testing-plugins 4 | pytest_plugins = ["pytester"] 5 | 6 | 7 | def test_option_false(testdir): 8 | p = testdir.makepyfile(""" 9 | def test_option(request): 10 | flag = request.config.getoption('pydocstyle') 11 | assert flag is False 12 | """) 13 | p = p.write(p.read() + "\n") 14 | result = testdir.runpytest() 15 | result.assert_outcomes(passed=1) 16 | 17 | 18 | def test_option_true(testdir): 19 | p = testdir.makepyfile(""" 20 | def test_option(request): 21 | flag = request.config.getoption('pydocstyle') 22 | assert flag is True 23 | """) 24 | p = p.write(p.read() + "\n") 25 | result = testdir.runpytest('--pydocstyle') 26 | result.assert_outcomes(passed=1) 27 | 28 | 29 | def test_ini(testdir): 30 | testdir.makeini(""" 31 | [pydocstyle] 32 | convention = numpy 33 | add-ignore = D100 34 | """) 35 | p = testdir.makepyfile(a=''' 36 | def hello(): 37 | """Print hello.""" 38 | print('hello') 39 | ''') 40 | p = p.write(p.read() + "\n") 41 | result = testdir.runpytest('--pydocstyle') 42 | result.assert_outcomes(passed=1) 43 | 44 | 45 | def test_pytest_collect_file(testdir): 46 | testdir.tmpdir.ensure('a.py') 47 | testdir.tmpdir.ensure('b.py') 48 | testdir.tmpdir.ensure('c.txt') 49 | testdir.tmpdir.ensure('test_d.py') 50 | result = testdir.runpytest('--pydocstyle') 51 | # D100: Missing docstring in public module 52 | result.assert_outcomes(failed=2) 53 | 54 | 55 | def test_cache(testdir): 56 | # D100: Missing docstring in public module 57 | testdir.tmpdir.ensure('a.py') 58 | p = testdir.makepyfile(b='''\ 59 | """Test.""" 60 | def hello(): 61 | """Print hello.""" 62 | print('hello') 63 | ''') 64 | # first run 65 | result = testdir.runpytest('--pydocstyle') 66 | result.assert_outcomes(passed=1, failed=1) 67 | # second run 68 | result = testdir.runpytest('--pydocstyle') 69 | result.assert_outcomes(skipped=1, failed=1) 70 | 71 | 72 | def test_no_cacheprovider(testdir): 73 | # D100: Missing docstring in public module 74 | testdir.tmpdir.ensure('a.py') 75 | p = testdir.makepyfile(b='''\ 76 | """Test.""" 77 | def hello(): 78 | """Print hello.""" 79 | print('hello') 80 | ''') 81 | # first run 82 | result = testdir.runpytest('--pydocstyle', '-p', 'no:cacheprovider') 83 | result.assert_outcomes(passed=1, failed=1) 84 | # second run 85 | result = testdir.runpytest('--pydocstyle', '-p', 'no:cacheprovider') 86 | result.assert_outcomes(passed=1, failed=1) 87 | 88 | 89 | def test_strict(testdir): 90 | p = testdir.makepyfile(a=''' 91 | """Test strict.""" 92 | def test_blah(): 93 | """Test.""" 94 | pass 95 | ''') 96 | p = p.write(p.read() + "\n") 97 | result = testdir.runpytest('--strict-markers', '--pydocstyle') 98 | result.assert_outcomes(passed=1) 99 | 100 | 101 | def test_nodeid(testdir): 102 | p = testdir.makepyfile(nodeid=''' 103 | """Test _nodeid.""" 104 | def test_nodeid(): 105 | """Test.""" 106 | pass 107 | ''') 108 | p = p.write(p.read() + "\n") 109 | result = testdir.runpytest('-m', 'pydocstyle', '--pydocstyle', '-v') 110 | result.assert_outcomes(passed=1) 111 | result.stdout.fnmatch_lines(['nodeid.py::PYDOCSTYLE PASSED *']) 112 | 113 | 114 | class TestItem(object): 115 | 116 | def test_cache_key(self): 117 | assert pytest_pydocstyle.Item.CACHE_KEY == 'pydocstyle/mtimes' 118 | 119 | def test_init(self): 120 | pass 121 | 122 | def test_setup(self): 123 | pass 124 | 125 | def test_runtest(self): 126 | pass 127 | 128 | def test_repr_failure(self): 129 | pass 130 | 131 | def test_reportinfo(self): 132 | pass 133 | 134 | 135 | class TestPyDocStyleError(object): 136 | 137 | def test_subclass(self): 138 | assert issubclass(pytest_pydocstyle.PyDocStyleError, Exception) 139 | --------------------------------------------------------------------------------