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] 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(