diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index a232ec63..00000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,18 +0,0 @@ -[bumpversion] -current_version = 6.1.1 -commit = True -parse = (?P\d+)\.(?P\d+)\.(?P\d+)((?P.*))? -serialize = - {major}.{minor}.{patch}{release} - {major}.{minor}.{patch} -tag_name = {new_version} - -[bumpversion:part:release] -optional_value = production -values = - rc - production - -[bumpversion:file:setup.py] - -[bumpversion:file:src/pydocstyle/utils.py] diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 00000000..7359aece --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,38 @@ +--- +name: Test PyPI publish + +on: + release: + types: [prereleased] + +jobs: + build: + runs-on: ubuntu-latest + environment: pypi-dev + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install poetry + run: pipx install poetry + + - name: Setup Python + uses: actions/setup-python@v3 + with: + python-version: "3.7" + cache: "poetry" + + - name: Install dependencies + run: | + poetry env use "3.7" + poetry install + poetry config repositories.testpypi https://test.pypi.org/legacy/ + + - name: Bump version number + run: poetry version ${{ github.event.release.tag_name }} + + - name: Build package + run: poetry build + + - name: Publish package + run: poetry publish -r testpypi -u __token__ -p ${{ secrets.TEST_PYPI_PASSWORD }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..59f10fa1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +--- +name: PyPI publish + +on: + release: + types: [released] + +jobs: + build: + runs-on: ubuntu-latest + environment: pypi-prod + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install poetry + run: pipx install poetry + + - name: Setup Python + uses: actions/setup-python@v3 + with: + python-version: "3.7" + cache: "poetry" + + - name: Install dependencies + run: | + poetry env use "3.7" + poetry install + + - name: Bump version number + run: poetry version ${{ github.event.release.tag_name }} + + - name: Build package + run: poetry build + + - name: Publish package + run: poetry publish -u __token__ -p ${{ secrets.PYPI_PASSWORD }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1fdccd6e..c6b6d1ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,22 +1,28 @@ name: Run tests -on: [push, pull_request] - +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: test-latest: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install tox run: python -m pip install --upgrade pip tox - name: Run Tests - run: tox -e py,install,docs + run: make tests diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..04ddeb44 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,15 @@ + +tasks: + - init: pip install -r requirements.txt && pip install -e . + +github: + prebuilds: + master: true + branches: true + pullRequests: true + pullRequestsFromForks: true + addCheck: true + +vscode: + extensions: + - ms-python.python diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index cb55ee45..3009bf52 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -4,3 +4,4 @@ entry: pydocstyle language: python types: [python] + pass_file_names: false diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..1ae054c1 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +all: format tests + +format: + isort src/pydocstyle + black src/pydocstyle + +tests: + tox -e py,install diff --git a/README.rst b/README.rst index a4e8aa7a..a4e800f2 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,10 @@ pydocstyle - docstring style checker .. image:: https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336 :target: https://pycqa.github.io/isort/ +.. image:: https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod + :target: https://gitpod.io/#https://github.com/PyCQA/pydocstyle + :alt: Gitpod ready-to-code + **pydocstyle** is a static analysis tool for checking compliance with Python docstring conventions. @@ -28,7 +32,7 @@ docstring conventions. `PEP 257 `_ out of the box, but it should not be considered a reference implementation. -**pydocstyle** supports Python 3.6, 3.7 and 3.8. +**pydocstyle** supports Python 3.6+. Quick Start @@ -43,7 +47,7 @@ Install Run -^^^^ +^^^ .. code:: @@ -56,12 +60,22 @@ Run D201: No blank lines allowed before function docstring (found 1) ... +Develop +^^^^^^^ + +You can use Gitpod to run pre-configured dev environment in the cloud right from your browser - + +.. image:: https://gitpod.io/button/open-in-gitpod.svg + :target: https://gitpod.io/#https://github.com/PyCQA/pydocstyle + :alt: Open in Gitpod + +Before submitting a PR make sure that you run `make all`. Links ----- -* `Read the full documentation here `_. +* `Read the full documentation here `_. -* `Fork pydocstyle on GitHub `_. +* `Fork pydocstyle on GitHub `_. * `PyPI project page `_. diff --git a/docs/conf.py b/docs/conf.py index 6f6412a5..b492c1b7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -134,7 +134,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +#html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied diff --git a/docs/error_codes.rst b/docs/error_codes.rst index 0808ed39..d58e5020 100644 --- a/docs/error_codes.rst +++ b/docs/error_codes.rst @@ -19,7 +19,7 @@ D214, D215, D404, D405, D406, D407, D408, D409, D410, D411, D413, D415, D416 and D417. The ``numpy`` convention added in v2.0.0 supports the `numpydoc docstring -`_ standard. This checks all of of the +`_ standard. This checks all of the errors except for D107, D203, D212, D213, D402, D413, D415, D416, and D417. The ``google`` convention added in v4.0.0 supports the `Google Python Style @@ -27,7 +27,7 @@ Guide `_. This checks for all the errors except D203, D204, D213, D215, D400, D401, D404, D406, D407, D408, D409 and D413. -These conventions may be specified using `--convention=` when +These conventions may be specified using ``--convention=`` when running pydocstyle from the command line or by specifying the convention in a configuration file. See the :ref:`cli_usage` section for more details. diff --git a/docs/index.rst b/docs/index.rst index 991a01e4..a7cb5807 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,7 +8,9 @@ docstring conventions. `PEP 257 `_ out of the box, but it should not be considered a reference implementation. -**pydocstyle** supports Python 3.6, 3.7 and 3.8. +**pydocstyle** supports Python 3.7 through 3.11. + +Although pydocstyle is tries to be compatible with Python 3.6, it is not tested. .. include:: quickstart.rst diff --git a/docs/release_notes.rst b/docs/release_notes.rst index 75df39df..bb4595ec 100644 --- a/docs/release_notes.rst +++ b/docs/release_notes.rst @@ -4,6 +4,42 @@ Release Notes **pydocstyle** version numbers follow the `Semantic Versioning `_ specification. +6.2.3 - January 8th, 2023 +--------------------------- + +Bug Fixes + +* Fix decorator parsing for async function. Resolves some false positives + with async functions and ``overload``. (#577) +* Obey match rules in pre-commit usage (#610). + +6.2.2 - January 3rd, 2023 +--------------------------- + +Bug Fixes + +* Fix false positives of D417 in google convention docstrings (#619). + +6.2.1 - January 3rd, 2023 +--------------------------- + +Bug Fixes + +* Use tomllib/tomli to correctly read .toml files (#599, #600). + +6.2.0 - January 2nd, 2023 +--------------------------- + +New Features + +* Allow for hanging indent when documenting args in Google style. (#449) +* Add support for `property_decorators` config to ignore D401. +* Add support for Python 3.10 (#554). +* Replace D10X errors with D419 if docstring exists but is empty (#559). + +Bug Fixes + +* Fix ``--match`` option to only consider filename when matching full paths (#550). 6.1.1 - May 17th, 2021 --------------------------- diff --git a/docs/snippets/config.rst b/docs/snippets/config.rst index 6d32e48c..3c7b5eea 100644 --- a/docs/snippets/config.rst +++ b/docs/snippets/config.rst @@ -44,6 +44,7 @@ Available options are: * ``match`` * ``match_dir`` * ``ignore_decorators`` +* ``property_decorators`` See the :ref:`cli_usage` section for more information. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..566a2ac4 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,82 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "importlib-metadata" +version = "4.8.3" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"}, + {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"}, +] + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy", "pytest-perf (>=0.9.2)"] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" +category = "main" +optional = true +python-versions = ">=3.6" +files = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.1.1" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, +] + +[[package]] +name = "zipp" +version = "3.6.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, +] + +[package.extras] +docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] + +[extras] +toml = ["tomli"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.6" +content-hash = "1abb1b7c1fa0c27846501ad1b5d7916eb5ec6e7961eab46ced6887d16428977a" diff --git a/pyproject.toml b/pyproject.toml index c6e2fb8e..84bfe0d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ +[tool.poetry] +name = "pydocstyle" +version = "0.0.0-dev" +description = "Python docstring style checker" +authors = ["Amir Rachum ", "Sambhav Kothari =1.2.3", optional = true, python = "<3.11"} +importlib-metadata = {version = ">=2.0.0,<5.0.0", python = "<3.8"} + +[tool.poetry.extras] +toml = ["tomli"] + +[tool.poetry.scripts] +pydocstyle = "pydocstyle.cli:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + [tool.black] line-length = 79 target-version = ['py36'] @@ -7,3 +43,16 @@ skip-string-normalization = true profile = "black" src_paths = ["src/pydocstyle"] line_length = 79 + +[tool.mypy] +ignore_missing_imports = true +strict_optional = true +disallow_incomplete_defs = true + +[tool.pytest.ini_options] +norecursedirs = ["docs", ".tox"] +addopts = """ + -vv + -rw + --cache-clear + """ diff --git a/requirements/docs.txt b/requirements/docs.txt index 623c766f..e7a7dcc3 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,4 +1,5 @@ +Jinja2 +sphinx sphinx_rtd_theme -# Pinned to 1.6.2 due to a bug in 1.6.3. See GitHub PR #270 for details. -# TODO: remove this restriction once 1.6.4 or later is released. -sphinx==1.6.2 +# adding . so that pydocstyle gets installed +. diff --git a/requirements/runtime.txt b/requirements/runtime.txt index 80302751..8b006549 100644 --- a/requirements/runtime.txt +++ b/requirements/runtime.txt @@ -1,2 +1,3 @@ -snowballstemmer==1.2.1 -toml==0.10.2 +snowballstemmer>=1.2.1 +tomli>=1.2.3; python_version < "3.11" +importlib-metadata<5.0.0,>=2.0.0; python_version < "3.8" diff --git a/requirements/tests.txt b/requirements/tests.txt index 947eea4a..f236d68b 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,5 +1,5 @@ -pytest==3.0.2 -pytest-pep8==1.0.6 -mypy==0.782 -black==20.8b1 +pytest==6.2.5 +mypy==0.930 +black==22.3 isort==5.4.2 +types-setuptools diff --git a/setup.py b/setup.py deleted file mode 100644 index 89adc034..00000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -from setuptools import setup - -# Do not update the version manually - it is managed by `bumpversion`. -version = '6.1.1' - - -requirements = [ - 'snowballstemmer', -] -extra_requirements = { - 'toml': ['toml'], -} - - -setup( - name='pydocstyle', - version=version, - description="Python docstring style checker", - long_description=open('README.rst').read(), - license='MIT', - author='Amir Rachum', - author_email='amir@rachum.com', - url='https://github.com/PyCQA/pydocstyle/', - classifiers=[ - 'Intended Audience :: Developers', - 'Environment :: Console', - 'Development Status :: 5 - Production/Stable', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3 :: Only', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: MIT License', - ], - python_requires='>=3.6', - keywords='pydocstyle, PEP 257, pep257, PEP 8, pep8, docstrings', - packages=('pydocstyle',), - package_dir={'': 'src'}, - package_data={'pydocstyle': ['data/*.txt']}, - install_requires=requirements, - extras_require=extra_requirements, - entry_points={ - 'console_scripts': [ - 'pydocstyle = pydocstyle.cli:main', - ], - }, - project_urls={ - 'Release Notes': 'https://www.pydocstyle.org/en/latest/release_notes.html', - }, -) diff --git a/src/pydocstyle/__init__.py b/src/pydocstyle/__init__.py index 3fb5499e..363ea3ff 100644 --- a/src/pydocstyle/__init__.py +++ b/src/pydocstyle/__init__.py @@ -1,5 +1,6 @@ +from ._version import __version__ + # Temporary hotfix for flake8-docstrings from .checker import ConventionChecker, check from .parser import AllError -from .utils import __version__ from .violations import Error, conventions diff --git a/src/pydocstyle/_version.py b/src/pydocstyle/_version.py new file mode 100644 index 00000000..185d331f --- /dev/null +++ b/src/pydocstyle/_version.py @@ -0,0 +1,13 @@ +import sys + +if sys.version_info[:2] >= (3, 8): + from importlib import metadata +else: + import importlib_metadata as metadata # pragma: no cover + +# Used to automatically set version number from github actions +# as well as not break when being tested locally +try: + __version__ = metadata.version(__package__) +except metadata.PackageNotFoundError: # pragma: no cover + __version__ = "0.0.0" diff --git a/src/pydocstyle/checker.py b/src/pydocstyle/checker.py index 41e3f35f..5cefc760 100644 --- a/src/pydocstyle/checker.py +++ b/src/pydocstyle/checker.py @@ -6,6 +6,7 @@ from collections import namedtuple from itertools import chain, takewhile from re import compile as re +from textwrap import dedent from . import violations from .config import IllegalConfiguration @@ -122,6 +123,8 @@ class ConventionChecker: r"\s*" # Followed by a colon r":" + # Might have a new line and leading whitespace + r"\n?\s*" # Followed by 1 or more characters - which is the docstring for the parameter ".+" ) @@ -131,8 +134,12 @@ def check_source( source, filename, ignore_decorators=None, + property_decorators=None, ignore_inline_noqa=False, ): + self.property_decorators = ( + {} if property_decorators is None else property_decorators + ) module = parse(StringIO(source), filename) for definition in module: for this_check in self.checks: @@ -192,12 +199,7 @@ def check_docstring_missing(self, definition, docstring): with a single underscore. """ - if ( - not docstring - and definition.is_public - or docstring - and is_blank(ast.literal_eval(docstring)) - ): + if not docstring and definition.is_public: codes = { Module: violations.D100, Class: violations.D101, @@ -223,6 +225,18 @@ def check_docstring_missing(self, definition, docstring): } return codes[type(definition)]() + @check_for(Definition, terminal=True) + def check_docstring_empty(self, definition, docstring): + """D419: Docstring is empty. + + If the user provided a docstring but it was empty, it is like they never provided one. + + NOTE: This used to report as D10X errors. + + """ + if docstring and is_blank(ast.literal_eval(docstring)): + return violations.D419() + @check_for(Definition) def check_one_liners(self, definition, docstring): """D200: One-liner docstrings should fit on one line with quotes. @@ -500,7 +514,11 @@ def check_imperative_mood(self, function, docstring): # def context "Returns the pathname ...". """ - if docstring and not function.is_test: + if ( + docstring + and not function.is_test + and not function.is_property(self.property_decorators) + ): stripped = ast.literal_eval(docstring).strip() if stripped: first_word = strip_non_alphanumeric(stripped.split()[0]) @@ -828,10 +846,52 @@ def _check_args_section(docstring, definition, context): * The section documents all function arguments (D417) except `self` or `cls` if it is a method. + Documentation for each arg should start at the same indentation + level. For example, in this case x and y are distinguishable:: + + Args: + x: Lorem ipsum dolor sit amet + y: Ut enim ad minim veniam + + In the case below, we only recognize x as a documented parameter + because the rest of the content is indented as if it belongs + to the description for x:: + + Args: + x: Lorem ipsum dolor sit amet + y: Ut enim ad minim veniam """ docstring_args = set() - for line in context.following_lines: - match = ConventionChecker.GOOGLE_ARGS_REGEX.match(line) + + # normalize leading whitespace + if context.following_lines: + # any lines with shorter indent than the first one should be disregarded + first_line = context.following_lines[0] + leading_whitespaces = first_line[: -len(first_line.lstrip())] + + args_content = dedent( + "\n".join( + [ + line + for line in context.following_lines + if line.startswith(leading_whitespaces) or line == "" + ] + ) + ).strip() + + args_sections = [] + for line in args_content.splitlines(keepends=True): + if not line[:1].isspace(): + # This line is the start of documentation for the next + # parameter because it doesn't start with any whitespace. + args_sections.append(line) + else: + # This is a continuation of documentation for the last + # parameter because it does start with whitespace. + args_sections[-1] += line + + for section in args_sections: + match = ConventionChecker.GOOGLE_ARGS_REGEX.match(section) if match: docstring_args.add(match.group(1)) yield from ConventionChecker._check_missing_args( @@ -1040,6 +1100,7 @@ def check( select=None, ignore=None, ignore_decorators=None, + property_decorators=None, ignore_inline_noqa=False, ): """Generate docstring errors that exist in `filenames` iterable. @@ -1092,7 +1153,11 @@ def check( with tk.open(filename) as file: source = file.read() for error in ConventionChecker().check_source( - source, filename, ignore_decorators, ignore_inline_noqa + source, + filename, + ignore_decorators, + property_decorators, + ignore_inline_noqa, ): code = getattr(error, 'code', None) if code in checked_codes: diff --git a/src/pydocstyle/cli.py b/src/pydocstyle/cli.py index 21fc490c..241894fd 100644 --- a/src/pydocstyle/cli.py +++ b/src/pydocstyle/cli.py @@ -42,12 +42,14 @@ def run_pydocstyle(): filename, checked_codes, ignore_decorators, + property_decorators, ) in conf.get_files_to_check(): errors.extend( check( (filename,), select=checked_codes, ignore_decorators=ignore_decorators, + property_decorators=property_decorators, ) ) except IllegalConfiguration as error: diff --git a/src/pydocstyle/config.py b/src/pydocstyle/config.py index ae1e6b58..c05f7dc2 100644 --- a/src/pydocstyle/config.py +++ b/src/pydocstyle/config.py @@ -4,19 +4,24 @@ import itertools import operator import os +import sys from collections import namedtuple from collections.abc import Set from configparser import NoOptionError, NoSectionError, RawConfigParser from functools import reduce from re import compile as re -from .utils import __version__, log +from ._version import __version__ +from .utils import log from .violations import ErrorRegistry, conventions -try: - import toml -except ImportError: # pragma: no cover - toml = None # type: ignore +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib + except ImportError: # pragma: no cover + tomllib = None # type: ignore def check_initialized(method): @@ -59,15 +64,15 @@ def read(self, filenames, encoding=None): read_ok = [] for filename in filenames: try: - with open(filename, encoding=encoding) as fp: - if not toml: + with open(filename, "rb") as fp: + if not tomllib: log.warning( "The %s configuration file was ignored, " - "because the `toml` package is not installed.", + "because the `tomli` package is not installed.", filename, ) continue - self._config.update(toml.load(fp)) + self._config.update(tomllib.load(fp)) except OSError: continue if isinstance(filename, os.PathLike): @@ -186,6 +191,9 @@ class ConfigurationParser: DEFAULT_MATCH_RE = r'(?!test_).*\.py' DEFAULT_MATCH_DIR_RE = r'[^\.].*' DEFAULT_IGNORE_DECORATORS_RE = '' + DEFAULT_PROPERTY_DECORATORS = ( + "property,cached_property,functools.cached_property" + ) DEFAULT_CONVENTION = conventions.pep257 PROJECT_CONFIG_FILES = ( @@ -266,30 +274,46 @@ def _get_ignore_decorators(conf): re(conf.ignore_decorators) if conf.ignore_decorators else None ) + def _get_property_decorators(conf): + """Return the `property_decorators` as None or set.""" + return ( + set(conf.property_decorators.split(",")) + if conf.property_decorators + else None + ) + for name in self._arguments: if os.path.isdir(name): for root, dirs, filenames in os.walk(name): config = self._get_config(os.path.abspath(root)) match, match_dir = _get_matches(config) ignore_decorators = _get_ignore_decorators(config) + property_decorators = _get_property_decorators(config) # Skip any dirs that do not match match_dir dirs[:] = [d for d in dirs if match_dir(d)] - for filename in filenames: + for filename in map(os.path.basename, filenames): if match(filename): full_path = os.path.join(root, filename) yield ( full_path, list(config.checked_codes), ignore_decorators, + property_decorators, ) else: config = self._get_config(os.path.abspath(name)) match, _ = _get_matches(config) ignore_decorators = _get_ignore_decorators(config) - if match(name): - yield (name, list(config.checked_codes), ignore_decorators) + property_decorators = _get_property_decorators(config) + if match(os.path.basename(name)): + yield ( + name, + list(config.checked_codes), + ignore_decorators, + property_decorators, + ) # --------------------------- Private Methods ----------------------------- @@ -485,7 +509,12 @@ def _merge_configuration(self, parent_config, child_options): self._set_add_options(error_codes, child_options) kwargs = dict(checked_codes=error_codes) - for key in ('match', 'match_dir', 'ignore_decorators'): + for key in ( + 'match', + 'match_dir', + 'ignore_decorators', + 'property_decorators', + ): kwargs[key] = getattr(child_options, key) or getattr( parent_config, key ) @@ -519,9 +548,15 @@ def _create_check_config(cls, options, use_defaults=True): checked_codes = cls._get_checked_errors(options) kwargs = dict(checked_codes=checked_codes) - for key in ('match', 'match_dir', 'ignore_decorators'): + defaults = { + 'match': "MATCH_RE", + 'match_dir': "MATCH_DIR_RE", + 'ignore_decorators': "IGNORE_DECORATORS_RE", + 'property_decorators': "PROPERTY_DECORATORS", + } + for key, default in defaults.items(): kwargs[key] = ( - getattr(cls, f'DEFAULT_{key.upper()}_RE') + getattr(cls, f"DEFAULT_{default}") if getattr(options, key) is None and use_defaults else getattr(options, key) ) @@ -855,6 +890,19 @@ def _create_option_parser(cls): ) ), ) + option( + '--property-decorators', + metavar='', + default=None, + help=( + "consider any method decorated with one of these " + "decorators as a property, and consequently allow " + "a docstring which is not in imperative mood; default " + "is --property-decorators='{}'".format( + cls.DEFAULT_PROPERTY_DECORATORS + ) + ), + ) return parser @@ -862,7 +910,13 @@ def _create_option_parser(cls): # Check configuration - used by the ConfigurationParser class. CheckConfiguration = namedtuple( 'CheckConfiguration', - ('checked_codes', 'match', 'match_dir', 'ignore_decorators'), + ( + 'checked_codes', + 'match', + 'match_dir', + 'ignore_decorators', + 'property_decorators', + ), ) diff --git a/src/pydocstyle/parser.py b/src/pydocstyle/parser.py index a6f1ed0b..cc768bbe 100644 --- a/src/pydocstyle/parser.py +++ b/src/pydocstyle/parser.py @@ -213,10 +213,16 @@ def is_public(self): @property def is_overload(self): """Return True iff the method decorated with overload.""" - for decorator in self.decorators: - if decorator.name == "overload": - return True - return False + return any( + decorator.name == "overload" for decorator in self.decorators + ) + + def is_property(self, property_decorator_names): + """Return True if the method is decorated with any property decorator.""" + return any( + decorator.name in property_decorator_names + for decorator in self.decorators + ) @property def is_test(self): @@ -486,6 +492,7 @@ def parse_decorators(self): self.current.value, ) if self.current.kind == tk.NAME and self.current.value in [ + 'async', 'def', 'class', ]: diff --git a/src/pydocstyle/utils.py b/src/pydocstyle/utils.py index 42b39bf5..c4e3295f 100644 --- a/src/pydocstyle/utils.py +++ b/src/pydocstyle/utils.py @@ -1,12 +1,10 @@ """General shared utilities.""" -import ast import logging import re from itertools import tee, zip_longest from typing import Any, Iterable, Tuple # Do not update the version manually - it is managed by `bumpversion`. -__version__ = '6.1.1' log = logging.getLogger(__name__) #: Regular expression for stripping non-alphanumeric characters diff --git a/src/pydocstyle/violations.py b/src/pydocstyle/violations.py index 60fc064e..8156921a 100644 --- a/src/pydocstyle/violations.py +++ b/src/pydocstyle/violations.py @@ -415,6 +415,10 @@ def to_rst(cls) -> str: 'D418', 'Function/ Method decorated with @overload shouldn\'t contain a docstring', ) +D419 = D4xx.create_error( + 'D419', + 'Docstring is empty', +) class AttrDict(dict): diff --git a/src/tests/test_cases/capitalization.py b/src/tests/test_cases/capitalization.py index 91ecf45c..a15c79f3 100644 --- a/src/tests/test_cases/capitalization.py +++ b/src/tests/test_cases/capitalization.py @@ -13,7 +13,7 @@ def not_capitalized(): # Make sure empty docstrings don't generate capitalization errors. -@expect("D103: Missing docstring in public function") +@expect("D419: Docstring is empty") def empty_docstring(): """""" diff --git a/src/tests/test_cases/sections.py b/src/tests/test_cases/sections.py index d671102b..5bf9a7be 100644 --- a/src/tests/test_cases/sections.py +++ b/src/tests/test_cases/sections.py @@ -318,6 +318,17 @@ def test_method(self, test, another_test, _): # noqa: D213, D407 """ + def test_detailed_description(self, test, another_test, _): # noqa: D213, D407 + """Test a valid args section. + + Args: + test: A parameter. + another_test: Another parameter. + + Detailed description. + + """ + @expect("D417: Missing argument descriptions in the docstring " "(argument(s) test, y, z are missing descriptions in " "'test_missing_args' docstring)", arg_count=5) @@ -367,10 +378,7 @@ def test_missing_docstring(a, b): # noqa: D213, D407 """ @staticmethod - @expect("D417: Missing argument descriptions in the docstring " - "(argument(s) skip, verbose are missing descriptions in " - "'test_missing_docstring_another' docstring)", arg_count=2) - def test_missing_docstring_another(skip, verbose): # noqa: D213, D407 + def test_hanging_indent(skip, verbose): # noqa: D213, D407 """Do stuff. Args: diff --git a/src/tests/test_cases/test.py b/src/tests/test_cases/test.py index 49fd471a..1cbd8ec4 100644 --- a/src/tests/test_cases/test.py +++ b/src/tests/test_cases/test.py @@ -13,7 +13,7 @@ class class_: - expect('meta', 'D106: Missing docstring in public nested class') + expect('meta', 'D419: Docstring is empty') class meta: """""" @@ -42,6 +42,11 @@ def overloaded_method(a): "D418: Function/ Method decorated with @overload" " shouldn't contain a docstring") + @property + def foo(self): + """The foo of the thing, which isn't in imperitive mood.""" + return "hello" + @expect('D102: Missing docstring in public method') def __new__(self=None): pass @@ -59,13 +64,13 @@ def __call__(self=None, x=None, y=None, z=None): pass -@expect('D103: Missing docstring in public function') +@expect('D419: Docstring is empty') def function(): """ """ def ok_since_nested(): pass - @expect('D103: Missing docstring in public function') + @expect('D419: Docstring is empty') def nested(): '' diff --git a/src/tests/test_decorators.py b/src/tests/test_decorators.py index 443dcd06..6fd050b8 100644 --- a/src/tests/test_decorators.py +++ b/src/tests/test_decorators.py @@ -1,6 +1,6 @@ """Unit test for pydocstyle module decorator handling. -Use tox or py.test to run the test suite. +Use tox or pytest to run the test suite. """ import io @@ -130,6 +130,21 @@ def some_method(self): assert 'first_decorator' == decorators[0].name assert '' == decorators[0].arguments + def test_parse_async_function_decorator(self): + """Decorators for async functions are also accumulated.""" + code = textwrap.dedent("""\ + @first_decorator + async def some_method(self): + pass + """) + + module = checker.parse(io.StringIO(code), 'dummy.py') + decorators = module.children[0].decorators + + assert 1 == len(decorators) + assert 'first_decorator' == decorators[0].name + assert '' == decorators[0].arguments + def test_parse_method_nested_decorator(self): """Method decorators are accumulated for nested methods.""" code = textwrap.dedent("""\ diff --git a/src/tests/test_definitions.py b/src/tests/test_definitions.py index 3971f0a0..c23192f9 100644 --- a/src/tests/test_definitions.py +++ b/src/tests/test_definitions.py @@ -5,6 +5,9 @@ import pytest from pydocstyle.violations import Error, ErrorRegistry from pydocstyle.checker import check +from pydocstyle.config import ConfigurationParser + +DEFAULT_PROPERTY_DECORATORS = ConfigurationParser.DEFAULT_PROPERTY_DECORATORS @pytest.mark.parametrize('test_case', [ @@ -35,10 +38,14 @@ def test_complex_file(test_case): test_case_file = os.path.join(test_case_dir, 'test_cases', test_case + '.py') - results = list(check([test_case_file], - select=set(ErrorRegistry.get_error_codes()), - ignore_decorators=re.compile( - 'wraps|ignored_decorator'))) + results = list( + check( + [test_case_file], + select=set(ErrorRegistry.get_error_codes()), + ignore_decorators=re.compile('wraps|ignored_decorator'), + property_decorators=DEFAULT_PROPERTY_DECORATORS, + ) + ) for error in results: assert isinstance(error, Error) results = {(e.definition.name, e.message) for e in results} diff --git a/src/tests/test_integration.py b/src/tests/test_integration.py index eb4994ff..2f2f57c6 100644 --- a/src/tests/test_integration.py +++ b/src/tests/test_integration.py @@ -1,4 +1,4 @@ -"""Use tox or py.test to run the test-suite.""" +"""Use tox or pytest to run the test-suite.""" from collections import namedtuple @@ -121,7 +121,7 @@ def __exit__(self, *args, **kwargs): pass -@pytest.yield_fixture(scope="module") +@pytest.fixture(scope="module") def install_package(request): """Install the package in development mode for the tests. @@ -138,7 +138,7 @@ def install_package(request): ) -@pytest.yield_fixture(scope="function", params=['ini', 'toml']) +@pytest.fixture(scope="function", params=['ini', 'toml']) def env(request): """Add a testing environment to a test method.""" sandbox_settings = { @@ -621,6 +621,36 @@ def overloaded_func(a): assert 'D103' not in out +def test_overload_async_function(env): + """Async functions decorated with @overload trigger D418 error.""" + with env.open('example.py', 'wt') as example: + example.write(textwrap.dedent('''\ + from typing import overload + + + @overload + async def overloaded_func(a: int) -> str: + ... + + + @overload + async def overloaded_func(a: str) -> str: + """Foo bar documentation.""" + ... + + + async def overloaded_func(a): + """Foo bar documentation.""" + return str(a) + + ''')) + env.write_config(ignore="D100") + out, err, code = env.invoke() + assert code == 1 + assert 'D418' in out + assert 'D103' not in out + + def test_overload_method(env): """Methods decorated with @overload trigger D418 error.""" with env.open('example.py', 'wt') as example: @@ -714,6 +744,36 @@ def overloaded_func(a): assert code == 0 +def test_overload_async_function_valid(env): + """Valid case for overload decorated async functions. + + This shouldn't throw any errors. + """ + with env.open('example.py', 'wt') as example: + example.write(textwrap.dedent('''\ + from typing import overload + + + @overload + async def overloaded_func(a: int) -> str: + ... + + + @overload + async def overloaded_func(a: str) -> str: + ... + + + async def overloaded_func(a): + """Foo bar documentation.""" + return str(a) + + ''')) + env.write_config(ignore="D100") + out, err, code = env.invoke() + assert code == 0 + + def test_overload_nested_function(env): """Nested functions decorated with @overload trigger D418 error.""" with env.open('example.py', 'wt') as example: @@ -1489,3 +1549,25 @@ def test_comment_with_noqa_plus_docstring_file(env): out, _, code = env.invoke() assert '' == out assert code == 0 + + +def test_match_considers_basenames_for_path_args(env): + """Test that `match` option only considers basenames for path arguments. + + The test environment consists of a single empty module `test_a.py`. The + match option is set to a pattern that ignores test_ prefixed .py filenames. + When pydocstyle is invoked with full path to `test_a.py`, we expect it to + succeed since match option will match against just the file name and not + full path. + """ + # Ignore .py files prefixed with 'test_' + env.write_config(select='D100', match='(?!test_).+.py') + + # Create an empty module (violates D100) + with env.open('test_a.py', 'wt') as test: + test.write('') + + # env.invoke calls pydocstyle with full path to test_a.py + out, _, code = env.invoke(target='test_a.py') + assert '' == out + assert code == 0 diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index c327999e..f3b4a4c6 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -1,6 +1,6 @@ """Unit test for pydocstyle utils. -Use tox or py.test to run the test suite. +Use tox or pytest to run the test suite. """ from pydocstyle import utils diff --git a/tox.ini b/tox.ini index 4ff6d2bf..86cbaa48 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # install tox" and then run "tox" from this directory. [tox] -envlist = {py36,py37,py38,py39}-{tests,install},docs,install,py36-docs +envlist = py{36,37,38,39,310,311}-{tests,install},docs,install,py36-docs [testenv] download = true @@ -13,10 +13,10 @@ download = true setenv = LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 -# To pass arguments to py.test, use `tox [options] -- [pytest posargs]`. +# To pass arguments to pytest, use `tox [options] -- [pytest posargs]`. commands = - py.test --pep8 --cache-clear -vv src/tests {posargs} - mypy --config-file=tox.ini src/ + pytest src/tests {posargs} + mypy src/ black --check src/pydocstyle isort --check src/pydocstyle deps = @@ -26,7 +26,7 @@ deps = [testenv:install] skip_install = True commands = - python setup.py bdist_wheel + pip wheel . -w dist --no-deps pip install --no-index --find-links=dist pydocstyle pydocstyle --help @@ -61,18 +61,13 @@ commands = {[testenv:install]commands} skip_install = {[testenv:install]skip_install} commands = {[testenv:install]commands} -[pytest] -pep8ignore = - test.py E701 E704 -norecursedirs = docs .tox -addopts = -rw +[testenv:py310-install] +skip_install = {[testenv:install]skip_install} +commands = {[testenv:install]commands} + + +[testenv:py311-install] +skip_install = {[testenv:install]skip_install} +commands = {[testenv:install]commands} -[pep257] -inherit = false -convention = pep257 -add-select = D404 -[mypy] -ignore_missing_imports = true -strict_optional = true -disallow_incomplete_defs = true