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/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 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..8e61dda 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, (ast.AsyncFunctionDef, ast.FunctionDef) + ) 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, (ast.AsyncFunctionDef, ast.FunctionDef)): + 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/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 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(