From 94039b23bedf98b6a6469312d04eecee9797a90f Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Sun, 12 Jan 2025 20:19:37 -0500 Subject: [PATCH] Add new config option for documenting star arguments (#206) --- .pre-commit-config.yaml | 8 ++ .../check_full_diff_in_changelog.py | 75 +++++++++++++++++++ CHANGELOG.md | 9 ++- docs/config_options.md | 27 ++++--- pydoclint/flake8_entry.py | 21 ++++++ pydoclint/main.py | 19 +++++ pydoclint/visitor.py | 10 +++ setup.cfg | 2 +- .../edge_cases/24_star_arguments/numpy.py | 42 +++++++++++ tests/test_main.py | 43 ++++++++++- 10 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 .pre_commit_helper_scripts/check_full_diff_in_changelog.py create mode 100644 tests/data/edge_cases/24_star_arguments/numpy.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d9424a..93998dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,3 +38,11 @@ repos: entry: python .pre_commit_helper_scripts/copy_readme.py language: system types: [python] + + - repo: local + hooks: + - id: check_full_diff_in_changelog + name: Check "full diff" exists in CHANGELOG.md + entry: python .pre_commit_helper_scripts/check_full_diff_in_changelog.py + language: python + additional_dependencies: ["markdown-it-py"] diff --git a/.pre_commit_helper_scripts/check_full_diff_in_changelog.py b/.pre_commit_helper_scripts/check_full_diff_in_changelog.py new file mode 100644 index 0000000..0bef271 --- /dev/null +++ b/.pre_commit_helper_scripts/check_full_diff_in_changelog.py @@ -0,0 +1,75 @@ +import sys + +from markdown_it import MarkdownIt + + +def parseChangelog(filePath: str) -> tuple[bool, list[str]]: + """ + Parses the changelog file and ensures each version section has "Full diff". + + Parameters + ---------- + filePath : str + Path to the CHANGELOG.md file. + + Returns + ------- + bool + True if all sections include a "Full diff", False otherwise. + list[str] + A list of version headers missing the "Full diff" section. + """ + with open(filePath, 'r', encoding='utf-8') as file: + content = file.read() + + # Parse the Markdown content + md = MarkdownIt() + tokens = md.parse(content) + + versionHeaders = [] + missingFullDiff = [] + + # Iterate through parsed tokens to find version sections + for i, token in enumerate(tokens): + if token.type == 'heading_open' and token.tag == 'h2': + # Extract version header text + header = tokens[i + 1].content + if header.startswith('[') and ' - ' in header: + versionHeaders.append((header, i)) + + # Check each version section for "Full diff" + for idx, (header, startIdx) in enumerate(versionHeaders): + if header.startswith('[0.0.1]'): + # The initial version shouldn't have a "Full diff" section. + continue + + endIdx = ( + versionHeaders[idx + 1][1] + if idx + 1 < len(versionHeaders) + else len(tokens) + ) + sectionTokens = tokens[startIdx:endIdx] + + # Check for "Full diff" in section content + if not any( + token.type == 'inline' and 'Full diff' in token.content + for token in sectionTokens + ): + missingFullDiff.append(header) + + return len(missingFullDiff) == 0, missingFullDiff + + +if __name__ == '__main__': + filePath = 'CHANGELOG.md' + isValid, missingSections = parseChangelog(filePath) + + if isValid: + print("All sections include a 'Full diff' section.") + sys.exit(0) + + print("The following sections are missing a 'Full diff':") + for section in missingSections: + print(section) + + sys.exit(1) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf2171..8d2efb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,17 @@ # Change Log -## [Unpublished] +## [0.5.19] - 2025-01-12 - Fixed - False positive DOC405 and DOC201 when we have bare return statements together with `yield` statements +- Added + - A new config option `--should-document-star-arguments` (if `False`, star + arguments such as `*args` and `**kwargs` should not be documented in the + docstring) + - A pre-commit step to check that "Full diff" is always added in CHANGELOG.md +- Full diff + - https://github.com/jsh9/pydoclint/compare/0.5.18...0.5.19 ## [0.5.18] - 2025-01-12 diff --git a/docs/config_options.md b/docs/config_options.md index 9cc0f53..dd74652 100644 --- a/docs/config_options.md +++ b/docs/config_options.md @@ -28,11 +28,12 @@ page: - [15. `--should-document-private-class-attributes` (shortform: `-sdpca`, default: `False`)](#15---should-document-private-class-attributes-shortform--sdpca-default-false) - [16. `--treat-property-methods-as-class-attributes` (shortform: `-tpmaca`, default: `False`)](#16---treat-property-methods-as-class-attributes-shortform--tpmaca-default-false) - [17. `--only-attrs-with-ClassVar-are-treated-as-class-attrs` (shortform: `-oawcv`, default: `False)](#17---only-attrs-with-classvar-are-treated-as-class-attrs-shortform--oawcv-default-false) -- [18. `--baseline`](#18---baseline) -- [19. `--generate-baseline` (default: `False`)](#19---generate-baseline-default-false) -- [20. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`)](#20---auto-regenerate-baseline-shortform--arb-default-true) -- [21. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#21---show-filenames-in-every-violation-message-shortform--sfn-default-false) -- [22. `--config` (default: `pyproject.toml`)](#22---config-default-pyprojecttoml) +- [18. `--should-document-star-arguments` (shortform: `-sdsa`, default: `True`)](#18---should-document-star-arguments-shortform--sdsa-default-true) +- [19. `--baseline`](#19---baseline) +- [20. `--generate-baseline` (default: `False`)](#20---generate-baseline-default-false) +- [21. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`)](#21---auto-regenerate-baseline-shortform--arb-default-true) +- [22. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#22---show-filenames-in-every-violation-message-shortform--sfn-default-false) +- [23. `--config` (default: `pyproject.toml`)](#23---config-default-pyprojecttoml) @@ -210,7 +211,13 @@ If True, only the attributes whose type annotations are wrapped within `ClassVar` (where `ClassVar` is imported from `typing`) are treated as class attributes, and all other attributes are treated as instance attributes. -## 18. `--baseline` +## 18. `--should-document-star-arguments` (shortform: `-sdsa`, default: `True`) + +If True, "star arguments" (such as `*args`, `**kwargs`, `**props`, etc.) +in the function signature should be documented in the docstring. If False, +they should not appear in the docstring. + +## 19. `--baseline` Baseline allows you to remember the current project state and then show only new violations, ignoring old ones. This can be very useful when you'd like to @@ -232,12 +239,12 @@ If `--generate-baseline` is not passed to _pydoclint_ (the default is `False`), _pydoclint_ will read your baseline file, and ignore all violations specified in that file. -## 19. `--generate-baseline` (default: `False`) +## 20. `--generate-baseline` (default: `False`) Required to use with `--baseline` option. If `True`, generate the baseline file that contains all current violations. -## 20. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`) +## 21. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`) If it's set to True, _pydoclint_ will automatically regenerate the baseline file every time you fix violations in the baseline and rerun _pydoclint_. @@ -245,7 +252,7 @@ file every time you fix violations in the baseline and rerun _pydoclint_. This saves you from having to manually regenerate the baseline file by setting `--generate-baseline=True` and run _pydoclint_. -## 21. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) +## 22. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) If False, in the terminal the violation messages are grouped by file names: @@ -279,7 +286,7 @@ This can be convenient if you would like to click on each violation message and go to the corresponding line in your IDE. (Note: not all terminal app offers this functionality.) -## 22. `--config` (default: `pyproject.toml`) +## 23. `--config` (default: `pyproject.toml`) The full path of the .toml config file that contains the config options. Note that the command line options take precedence over the .toml file. Look at this diff --git a/pydoclint/flake8_entry.py b/pydoclint/flake8_entry.py index 8b22eb2..34c83bd 100644 --- a/pydoclint/flake8_entry.py +++ b/pydoclint/flake8_entry.py @@ -210,6 +210,19 @@ def add_options(cls, parser: Any) -> None: # noqa: D102 ' treated as instance attributes.' ), ) + parser.add_option( + '-sdsa', + '--should-document-star-arguments', + action='store', + default='True', + parse_from_config=True, + help=( + 'If True, "star arguments" (such as *args, **kwargs,' + ' **props, etc.) in the function signature should be' + ' documented in the docstring. If False, they should not' + ' appear in the docstring.' + ), + ) @classmethod def parse_options(cls, options: Any) -> None: # noqa: D102 @@ -245,6 +258,9 @@ def parse_options(cls, options: Any) -> None: # noqa: D102 cls.only_attrs_with_ClassVar_are_treated_as_class_attrs = ( options.only_attrs_with_ClassVar_are_treated_as_class_attrs ) + cls.should_document_star_arguments = ( + options.should_document_star_arguments + ) cls.style = options.style def run(self) -> Generator[tuple[int, int, str, Any], None, None]: @@ -322,6 +338,10 @@ def run(self) -> Generator[tuple[int, int, str, Any], None, None]: '--treat-property-methods-as-class-attributes', self.treat_property_methods_as_class_attributes, ) + shouldDocumentStarArguments = self._bool( + '--should-document-star-arguments', + self.should_document_star_arguments, + ) if self.style not in {'numpy', 'google', 'sphinx'}: raise ValueError( @@ -351,6 +371,7 @@ def run(self) -> Generator[tuple[int, int, str, Any], None, None]: treatPropertyMethodsAsClassAttributes=( treatPropertyMethodsAsClassAttributes ), + shouldDocumentStarArguments=shouldDocumentStarArguments, style=self.style, ) v.visit(self._tree) diff --git a/pydoclint/main.py b/pydoclint/main.py index 824c143..b192156 100644 --- a/pydoclint/main.py +++ b/pydoclint/main.py @@ -249,6 +249,19 @@ def validateStyleValue( ' treated as instance attributes.' ), ) +@click.option( + '-sdsa', + '--should-document-star-arguments', + type=bool, + show_default=True, + default=True, + help=( + 'If True, "star arguments" (such as *args, **kwargs,' + ' **props, etc.) in the function signature should be' + ' documented in the docstring. If False, they should not' + ' appear in the docstring.' + ), +) @click.option( '--baseline', type=click.Path( @@ -351,6 +364,7 @@ def main( # noqa: C901 require_return_section_when_returning_nothing: bool, require_yield_section_when_yielding_nothing: bool, only_attrs_with_classvar_are_treated_as_class_attrs: bool, + should_document_star_arguments: bool, generate_baseline: bool, auto_regenerate_baseline: bool, baseline: str, @@ -450,6 +464,7 @@ def main( # noqa: C901 requireYieldSectionWhenYieldingNothing=( require_yield_section_when_yielding_nothing ), + shouldDocumentStarArguments=should_document_star_arguments, ) if generate_baseline: @@ -585,6 +600,7 @@ def _checkPaths( onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, + shouldDocumentStarArguments: bool = True, quiet: bool = False, exclude: str = '', ) -> dict[str, list[Violation]]: @@ -644,6 +660,7 @@ def _checkPaths( requireYieldSectionWhenYieldingNothing=( requireYieldSectionWhenYieldingNothing ), + shouldDocumentStarArguments=shouldDocumentStarArguments, ) allViolations[filename.as_posix()] = violationsInThisFile @@ -668,6 +685,7 @@ def _checkFile( onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, + shouldDocumentStarArguments: bool = True, ) -> list[Violation]: if not filename.is_file(): # sometimes folder names can end with `.py` return [] @@ -722,6 +740,7 @@ def _checkFile( requireYieldSectionWhenYieldingNothing=( requireYieldSectionWhenYieldingNothing ), + shouldDocumentStarArguments=shouldDocumentStarArguments, ) visitor.visit(tree) return visitor.violations diff --git a/pydoclint/visitor.py b/pydoclint/visitor.py index 4b4e2e5..05edc02 100644 --- a/pydoclint/visitor.py +++ b/pydoclint/visitor.py @@ -69,6 +69,7 @@ def __init__( onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, + shouldDocumentStarArguments: bool = True, ) -> None: self.style: str = style self.argTypeHintsInSignature: bool = argTypeHintsInSignature @@ -96,6 +97,7 @@ def __init__( self.requireYieldSectionWhenYieldingNothing: bool = ( requireYieldSectionWhenYieldingNothing ) + self.shouldDocumentStarArguments: bool = shouldDocumentStarArguments self.parent: ast.AST = ast.Pass() # keep track of parent node self.violations: list[Violation] = [] @@ -427,6 +429,14 @@ def checkArguments( # noqa: C901 [_ for _ in funcArgs.infoList if set(_.name) != {'_'}] ) + if not self.shouldDocumentStarArguments: + # This is "should not" rather than "need not", which means that + # if this config option is set to False, there CANNOT be + # documentation of star arguments in the docstring + funcArgs = ArgList( + [_ for _ in funcArgs.infoList if not _.name.startswith('*')] + ) + if docArgs.length == 0 and funcArgs.length == 0: return [] diff --git a/setup.cfg b/setup.cfg index 9adbf0c..8ad173c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pydoclint -version = 0.5.18 +version = 0.5.19 description = A Python docstring linter that checks arguments, returns, yields, and raises sections long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/data/edge_cases/24_star_arguments/numpy.py b/tests/data/edge_cases/24_star_arguments/numpy.py new file mode 100644 index 0000000..e22e4ab --- /dev/null +++ b/tests/data/edge_cases/24_star_arguments/numpy.py @@ -0,0 +1,42 @@ +# From: https://github.com/jsh9/pydoclint/issues/121 + +def function_1(arg1: int, *args: Any, **kwargs: Any) -> None: + """ + Do something + + Parameters + ---------- + arg1 : int + Arg 1 + """ + pass + + +def function_2(arg1: int, *args: Any, **kwargs: Any) -> None: + """ + Do something + + Parameters + ---------- + arg1 : int + Arg 1 + *args : Any + Args + **kwargs : Any + Kwargs + """ + pass + + +def function_3(arg1: int, *args: Any, **kwargs: Any) -> None: + """ + Do something + + Parameters + ---------- + arg1 : int + Arg 1 + **kwargs : Any + Kwargs + """ + pass diff --git a/tests/test_main.py b/tests/test_main.py index 4710a02..6198354 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1655,6 +1655,44 @@ def testNonAscii() -> None: 'Generator[...]/Iterator[...]): int; docstring "yields" section types:', ], ), + ( + '24_star_arguments/numpy.py', + {'style': 'numpy', 'shouldDocumentStarArguments': True}, + [ + 'DOC101: Function `function_1`: Docstring contains fewer arguments than in ' + 'function signature.', + 'DOC103: Function `function_1`: Docstring arguments are different from ' + 'function arguments. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Arguments in the function signature but not in the docstring: [**kwargs: ' + 'Any, *args: Any].', + 'DOC101: Function `function_3`: Docstring contains fewer arguments than in ' + 'function signature.', + 'DOC103: Function `function_3`: Docstring arguments are different from ' + 'function arguments. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Arguments in the function signature but not in the docstring: [*args: Any].', + ], + ), + ( + '24_star_arguments/numpy.py', + {'style': 'numpy', 'shouldDocumentStarArguments': False}, + [ + 'DOC102: Function `function_2`: Docstring contains more arguments than in ' + 'function signature.', + 'DOC103: Function `function_2`: Docstring arguments are different from ' + 'function arguments. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Arguments in the docstring but not in the function signature: [**kwargs: ' + 'Any, *args: Any].', + 'DOC102: Function `function_3`: Docstring contains more arguments than in ' + 'function signature.', + 'DOC103: Function `function_3`: Docstring arguments are different from ' + 'function arguments. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Arguments in the docstring but not in the function signature: [**kwargs: Any].', + ], + ), ], ) def testEdgeCases( @@ -1680,10 +1718,7 @@ def testPlayground() -> None: """ violations = _checkFile( filename=DATA_DIR / 'playground.py', - style='google', - skipCheckingRaises=True, - argTypeHintsInDocstring=False, - checkYieldTypes=False, + style='numpy', ) expected = [] assert list(map(str, violations)) == expected