From 675bc350103c20970f2fc32497c5171d788f4eed Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Fri, 12 Jul 2024 00:35:14 -0700 Subject: [PATCH 1/5] Add option to treat property methods as class attributes --- pydoclint/flake8_entry.py | 25 ++++++++++++ pydoclint/main.py | 27 +++++++++++++ pydoclint/utils/visitor_helper.py | 34 +++++++++++++--- pydoclint/visitor.py | 7 ++++ .../google.py | 29 ++++++++++++++ tests/test_main.py | 39 +++++++++++++++++++ 6 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/data/edge_cases/12_property_methods_as_class_attr/google.py diff --git a/pydoclint/flake8_entry.py b/pydoclint/flake8_entry.py index a76bc1c..20f86c8 100644 --- a/pydoclint/flake8_entry.py +++ b/pydoclint/flake8_entry.py @@ -179,6 +179,21 @@ def add_options(cls, parser): # noqa: D102 ' class attributes should not appear in the docstring.' ), ) + parser.add_option( + '-tpmaca', + '--treat-property-methods-as-class-attributes', + action='store', + default='True', + parse_from_config=True, + help=( + 'If True, treat @property methods as class properties. This means' + ' that they need to be documented in the "Attributes" section of' + ' the class docstring, and there cannot be any docstring under' + ' the @property methods. This option is only effective when' + ' --check-class-attributes is True. We recommend setting both' + ' this option and --check-class-attributes to True.' + ), + ) @classmethod def parse_options(cls, options): # noqa: D102 @@ -208,6 +223,9 @@ def parse_options(cls, options): # noqa: D102 cls.should_document_private_class_attributes = ( options.should_document_private_class_attributes ) + cls.treat_property_methods_as_class_attributes = ( + options.treat_property_methods_as_class_attributes + ) cls.style = options.style def run(self) -> Generator[Tuple[int, int, str, Any], None, None]: @@ -281,6 +299,10 @@ def run(self) -> Generator[Tuple[int, int, str, Any], None, None]: '--should-document-private-class-attributes', self.should_document_private_class_attributes, ) + treatPropertyMethodsAsClassAttributes = self._bool( + '--treat-property-methods-as-class-attributes', + self.treat_property_methods_as_class_attributes, + ) if self.style not in {'numpy', 'google', 'sphinx'}: raise ValueError( @@ -307,6 +329,9 @@ def run(self) -> Generator[Tuple[int, int, str, Any], None, None]: shouldDocumentPrivateClassAttributes=( shouldDocumentPrivateClassAttributes ), + treatPropertyMethodsAsClassAttributes=( + treatPropertyMethodsAsClassAttributes + ), style=self.style, ) v.visit(self._tree) diff --git a/pydoclint/main.py b/pydoclint/main.py index 628e4fc..3678874 100644 --- a/pydoclint/main.py +++ b/pydoclint/main.py @@ -218,6 +218,21 @@ def validateStyleValue( ' class attributes should not appear in the docstring.' ), ) +@click.option( + '-tpmaca', + '--treat-property-methods-as-class-attributes', + type=bool, + show_default=True, + default=True, + help=( + 'If True, treat @property methods as class properties. This means' + ' that they need to be documented in the "Attributes" section of' + ' the class docstring, and there cannot be any docstring under' + ' the @property methods. This option is only effective when' + ' --check-class-attributes is True. We recommend setting both' + ' this option and --check-class-attributes to True.' + ), +) @click.option( '--baseline', type=click.Path( @@ -304,6 +319,7 @@ def main( # noqa: C901 ignore_underscore_args: bool, check_class_attributes: bool, should_document_private_class_attributes: bool, + treat_property_methods_as_class_attributes: bool, require_return_section_when_returning_none: bool, require_return_section_when_returning_nothing: bool, require_yield_section_when_yielding_nothing: bool, @@ -392,6 +408,9 @@ def main( # noqa: C901 shouldDocumentPrivateClassAttributes=( should_document_private_class_attributes ), + treatPropertyMethodsAsClassAttributes=( + treat_property_methods_as_class_attributes + ), requireReturnSectionWhenReturningNothing=( require_return_section_when_returning_nothing ), @@ -508,6 +527,7 @@ def _checkPaths( ignoreUnderscoreArgs: bool = True, checkClassAttributes: bool = True, shouldDocumentPrivateClassAttributes: bool = False, + treatPropertyMethodsAsClassAttributes: bool = True, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, quiet: bool = False, @@ -557,6 +577,9 @@ def _checkPaths( shouldDocumentPrivateClassAttributes=( shouldDocumentPrivateClassAttributes ), + treatPropertyMethodsAsClassAttributes=( + treatPropertyMethodsAsClassAttributes + ), requireReturnSectionWhenReturningNothing=( requireReturnSectionWhenReturningNothing ), @@ -583,6 +606,7 @@ def _checkFile( ignoreUnderscoreArgs: bool = True, checkClassAttributes: bool = True, shouldDocumentPrivateClassAttributes: bool = False, + treatPropertyMethodsAsClassAttributes: bool = True, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, ) -> List[Violation]: @@ -605,6 +629,9 @@ def _checkFile( shouldDocumentPrivateClassAttributes=( shouldDocumentPrivateClassAttributes ), + treatPropertyMethodsAsClassAttributes=( + treatPropertyMethodsAsClassAttributes + ), requireReturnSectionWhenReturningNothing=( requireReturnSectionWhenReturningNothing ), diff --git a/pydoclint/utils/visitor_helper.py b/pydoclint/utils/visitor_helper.py index f052161..9a36189 100644 --- a/pydoclint/utils/visitor_helper.py +++ b/pydoclint/utils/visitor_helper.py @@ -5,6 +5,7 @@ from pydoclint.utils.annotation import unparseAnnotation from pydoclint.utils.arg import Arg, ArgList +from pydoclint.utils.astTypes import FuncOrAsyncFuncDef from pydoclint.utils.doc import Doc from pydoclint.utils.generic import ( appendArgsToCheckToV105, @@ -15,6 +16,7 @@ from pydoclint.utils.internal_error import InternalError from pydoclint.utils.return_anno import ReturnAnnotation from pydoclint.utils.return_arg import ReturnArg +from pydoclint.utils.special_methods import checkIsPropertyMethod from pydoclint.utils.violation import Violation from pydoclint.utils.yield_arg import YieldArg @@ -37,6 +39,7 @@ def checkClassAttributesAgainstClassDocstring( argTypeHintsInDocstring: bool, skipCheckingShortDocstrings: bool, shouldDocumentPrivateClassAttributes: bool, + treatPropertyMethodsAsClassAttributes: bool, ) -> None: """Check class attribute list against the attribute list in docstring""" classAttributes = _collectClassAttributes( @@ -45,7 +48,10 @@ def checkClassAttributesAgainstClassDocstring( shouldDocumentPrivateClassAttributes ), ) - actualArgs: ArgList = _convertClassAttributesIntoArgList(classAttributes) + actualArgs: ArgList = _convertClassAttributesIntoArgList( + classAttrs=classAttributes, + treatPropertyMethodsAsClassAttrs=treatPropertyMethodsAsClassAttributes, + ) classDocstring: str = getDocstring(node) @@ -122,12 +128,15 @@ def _collectClassAttributes( *, node: ast.ClassDef, shouldDocumentPrivateClassAttributes: bool, -) -> List[Union[ast.Assign, ast.AnnAssign]]: +) -> List[Union[ast.Assign, ast.AnnAssign, FuncOrAsyncFuncDef]]: if 'body' not in node.__dict__ or len(node.body) == 0: return [] attributes: List[Union[ast.Assign, ast.AnnAssign]] = [] for item in node.body: + # Notes: + # - ast.Assign are something like "attr1 = 1.5" + # - ast.AnnAssign are something like "attr2: float = 1.5" if isinstance(item, (ast.Assign, ast.AnnAssign)): classAttrName: str = _getClassAttrName(item) if shouldDocumentPrivateClassAttributes: @@ -136,6 +145,11 @@ def _collectClassAttributes( if not classAttrName.startswith('_'): attributes.append(item) + if isinstance(item, FuncOrAsyncFuncDef) and checkIsPropertyMethod( + item + ): + attributes.append(item) + return attributes @@ -150,10 +164,12 @@ def _getClassAttrName(attrItem: Union[ast.Assign, ast.AnnAssign]) -> str: def _convertClassAttributesIntoArgList( - classAttributes: List[Union[ast.Assign, ast.AnnAssign]], + *, + classAttrs: List[Union[ast.Assign, ast.AnnAssign, FuncOrAsyncFuncDef]], + treatPropertyMethodsAsClassAttrs: bool, ) -> ArgList: atl: List[Arg] = [] - for attr in classAttributes: + for attr in classAttrs: if isinstance(attr, ast.AnnAssign): atl.append(Arg.fromAstAnnAssign(attr)) elif isinstance(attr, ast.Assign): @@ -161,9 +177,17 @@ def _convertClassAttributesIntoArgList( atl.extend(ArgList.fromAstAssignWithTupleTarget(attr).infoList) else: atl.append(Arg.fromAstAssignWithNonTupleTarget(attr)) + elif isinstance(attr, FuncOrAsyncFuncDef): + if treatPropertyMethodsAsClassAttrs: + atl.append( + Arg( + name=attr.name, + typeHint=unparseAnnotation(attr.returns), + ) + ) else: raise InternalError( - f'Unkonwn type of class attribute: {type(attr)}' + f'Unknown type of class attribute: {type(attr)}' ) return ArgList(infoList=atl) diff --git a/pydoclint/visitor.py b/pydoclint/visitor.py index a41ced4..f501551 100644 --- a/pydoclint/visitor.py +++ b/pydoclint/visitor.py @@ -60,6 +60,7 @@ def __init__( ignoreUnderscoreArgs: bool = True, checkClassAttributes: bool = True, shouldDocumentPrivateClassAttributes: bool = False, + treatPropertyMethodsAsClassAttributes: bool = True, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, ) -> None: @@ -77,6 +78,9 @@ def __init__( self.shouldDocumentPrivateClassAttributes: bool = ( shouldDocumentPrivateClassAttributes ) + self.treatPropertyMethodsAsClassAttributes: bool = ( + treatPropertyMethodsAsClassAttributes + ) self.requireReturnSectionWhenReturningNothing: bool = ( requireReturnSectionWhenReturningNothing ) @@ -105,6 +109,9 @@ def visit_ClassDef(self, node: ast.ClassDef): # noqa: D102 shouldDocumentPrivateClassAttributes=( self.shouldDocumentPrivateClassAttributes ), + treatPropertyMethodsAsClassAttributes=( + self.treatPropertyMethodsAsClassAttributes + ), ) self.generic_visit(node) diff --git a/tests/data/edge_cases/12_property_methods_as_class_attr/google.py b/tests/data/edge_cases/12_property_methods_as_class_attr/google.py new file mode 100644 index 0000000..36c5b4f --- /dev/null +++ b/tests/data/edge_cases/12_property_methods_as_class_attr/google.py @@ -0,0 +1,29 @@ +class House: + """ + A house + + Attributes: + price (float): House price + + Args: + price_0 (float): House price + """ + + def __init__(self, price_0: float) -> None: + self._price = price_0 + + @property + def price(self) -> float: + """The house price""" + return self._price + + @price.setter + def price(self, new_price): + if new_price > 0 and isinstance(new_price, float): + self._price = new_price + else: + print('Please enter a valid price') + + @price.deleter + def price(self): + del self._price diff --git a/tests/test_main.py b/tests/test_main.py index 8d300eb..86508c2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1326,6 +1326,45 @@ def testNonAscii() -> None: 'correctly document class attributes.)', ], ), + ( + '12_property_methods_as_class_attr/google.py', + { + 'style': 'google', + 'checkClassAttributes': True, + 'treatPropertyMethodsAsClassAttributes': True, + }, + [], + ), + ( + '12_property_methods_as_class_attr/google.py', + { + 'style': 'google', + 'checkClassAttributes': True, + 'treatPropertyMethodsAsClassAttributes': True, + }, + [], + ), + ( + '12_property_methods_as_class_attr/google.py', + { + 'style': 'google', + 'checkClassAttributes': True, + 'treatPropertyMethodsAsClassAttributes': False, + }, + [ + 'DOC602: Class `House`: Class docstring contains more class attributes than ' + 'in actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `House`: Class docstring attributes are different from actual ' + 'class attributes. (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 actual class attributes: [price: ' + 'float]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + ), ], ) def testEdgeCases( From e483eeb9dc05f191fa06a236dd1959c4a31ca1a2 Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Fri, 12 Jul 2024 02:19:32 -0700 Subject: [PATCH 2/5] Fix tests in py38 and py39 --- pydoclint/utils/visitor_helper.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pydoclint/utils/visitor_helper.py b/pydoclint/utils/visitor_helper.py index 9a36189..607d6f1 100644 --- a/pydoclint/utils/visitor_helper.py +++ b/pydoclint/utils/visitor_helper.py @@ -26,6 +26,12 @@ ' on how to correctly document class attributes.)' ) +if sys.version_info <= (3, 9): + # Temporary treatment until we can fully deprecate Python 3.9 + FuncOrAsyncFuncDef_ = (ast.AsyncFunctionDef, ast.FunctionDef) +else: + FuncOrAsyncFuncDef_ = FuncOrAsyncFuncDef + def checkClassAttributesAgainstClassDocstring( *, @@ -145,7 +151,7 @@ def _collectClassAttributes( if not classAttrName.startswith('_'): attributes.append(item) - if isinstance(item, FuncOrAsyncFuncDef) and checkIsPropertyMethod( + if isinstance(item, FuncOrAsyncFuncDef_) and checkIsPropertyMethod( item ): attributes.append(item) @@ -177,7 +183,7 @@ def _convertClassAttributesIntoArgList( atl.extend(ArgList.fromAstAssignWithTupleTarget(attr).infoList) else: atl.append(Arg.fromAstAssignWithNonTupleTarget(attr)) - elif isinstance(attr, FuncOrAsyncFuncDef): + elif isinstance(attr, FuncOrAsyncFuncDef_): if treatPropertyMethodsAsClassAttrs: atl.append( Arg( From 38ec725ab3319119ccbce4146ed3ede180fa7e31 Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Fri, 12 Jul 2024 02:28:00 -0700 Subject: [PATCH 3/5] Fix tests (2nd attempt) --- pydoclint/utils/visitor_helper.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/pydoclint/utils/visitor_helper.py b/pydoclint/utils/visitor_helper.py index 607d6f1..8e61dda 100644 --- a/pydoclint/utils/visitor_helper.py +++ b/pydoclint/utils/visitor_helper.py @@ -26,12 +26,6 @@ ' on how to correctly document class attributes.)' ) -if sys.version_info <= (3, 9): - # Temporary treatment until we can fully deprecate Python 3.9 - FuncOrAsyncFuncDef_ = (ast.AsyncFunctionDef, ast.FunctionDef) -else: - FuncOrAsyncFuncDef_ = FuncOrAsyncFuncDef - def checkClassAttributesAgainstClassDocstring( *, @@ -151,9 +145,9 @@ def _collectClassAttributes( if not classAttrName.startswith('_'): attributes.append(item) - if isinstance(item, FuncOrAsyncFuncDef_) and checkIsPropertyMethod( - item - ): + if isinstance( + item, (ast.AsyncFunctionDef, ast.FunctionDef) + ) and checkIsPropertyMethod(item): attributes.append(item) return attributes @@ -183,7 +177,7 @@ def _convertClassAttributesIntoArgList( atl.extend(ArgList.fromAstAssignWithTupleTarget(attr).infoList) else: atl.append(Arg.fromAstAssignWithNonTupleTarget(attr)) - elif isinstance(attr, FuncOrAsyncFuncDef_): + elif isinstance(attr, (ast.AsyncFunctionDef, ast.FunctionDef)): if treatPropertyMethodsAsClassAttrs: atl.append( Arg( From b5fa4d33604cced92b6d6f0d7d9bcc46876c8232 Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Sun, 14 Jul 2024 10:41:04 -0700 Subject: [PATCH 4/5] Add documentation for new config options --- docs/config_options.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/config_options.md b/docs/config_options.md index 738f683..e9e062c 100644 --- a/docs/config_options.md +++ b/docs/config_options.md @@ -25,10 +25,12 @@ page: - [12. `--check-yield-types` (shortform: `-cyt`, default: `True`)](#12---check-yield-types-shortform--cyt-default-true) - [13. `--ignore-underscore-args` (shortform: `-iua`, default: `True`)](#13---ignore-underscore-args-shortform--iua-default-true) - [14. `--check-class-attributes` (shortform: `-cca`, default: `True`)](#14---check-class-attributes-shortform--cca-default-true) -- [15. `--baseline`](#15---baseline) -- [16. `--generate-baseline` (default: `False`)](#16---generate-baseline-default-false) -- [17. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#17---show-filenames-in-every-violation-message-shortform--sfn-default-false) -- [18. `--config` (default: `pyproject.toml`)](#18---config-default-pyprojecttoml) +- [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: `True`)](#16---treat-property-methods-as-class-attributes-shortform--tpmaca-default-true) +- [17. `--baseline`](#17---baseline) +- [18. `--generate-baseline` (default: `False`)](#18---generate-baseline-default-false) +- [19. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#19---show-filenames-in-every-violation-message-shortform--sfn-default-false) +- [20. `--config` (default: `pyproject.toml`)](#20---config-default-pyprojecttoml) @@ -187,7 +189,20 @@ Please read [this page](https://jsh9.github.io/pydoclint/checking_class_attributes.html) for more instructions. -## 15. `--baseline` +## 15. `--should-document-private-class-attributes` (shortform: `-sdpca`, default: `False`) + +If True, private class attributes (those that start with leading `_`) should be +documented. If False, they should not be documented. + +## 16. `--treat-property-methods-as-class-attributes` (shortform: `-tpmaca`, default: `True`) + +If True, treat `@property` methods as class properties. This means that they +need to be documented in the "Attributes" section of the class docstring, and +there cannot be any docstring under the @property methods. This option is only +effective when --check-class-attributes is True. We recommend setting both this +option and --check-class-attributes to True. + +## 17. `--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 @@ -207,12 +222,12 @@ project. If `--generate-baseline` is not passed (default value is `False`), _pydoclint_ will read your baseline file, and ignore all violations specified in that file. -## 16. `--generate-baseline` (default: `False`) +## 18. `--generate-baseline` (default: `False`) Required to use with `--baseline` option. If `True`, generate the baseline file that contains all current violations. -## 17. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) +## 19. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) If False, in the terminal the violation messages are grouped by file names: @@ -246,7 +261,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.) -## 18. `--config` (default: `pyproject.toml`) +## 20. `--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 From 8efa4fa2a1daf64c49f9a57095f9222880799975 Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Sun, 14 Jul 2024 10:44:36 -0700 Subject: [PATCH 5/5] Bump version and update changelog --- CHANGELOG.md | 5 ++++- setup.cfg | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a4df2..b2b7470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ # Change Log -## [unpublished] - 2024-07-04 +## [0.5.4] - 2024-07-14 - Added - An option `--should-document-private-class-attributes` (if False, private class attributes should not appear in the docstring) + - An option `--treat-property-methods-as-class-attributes` (if True, + `@property` methods are treated like class attributes and need to be + documented in the class docstring) ## [0.5.3] - 2024-06-26 diff --git a/setup.cfg b/setup.cfg index a080388..cf63676 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pydoclint -version = 0.5.3 +version = 0.5.4 description = A Python docstring linter that checks arguments, returns, yields, and raises sections long_description = file: README.md long_description_content_type = text/markdown