Skip to content

Commit

Permalink
Add option to treat property methods as class attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
jsh9 committed Jul 12, 2024
1 parent 3c39d2e commit 675bc35
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 5 deletions.
25 changes: 25 additions & 0 deletions pydoclint/flake8_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions pydoclint/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -557,6 +577,9 @@ def _checkPaths(
shouldDocumentPrivateClassAttributes=(
shouldDocumentPrivateClassAttributes
),
treatPropertyMethodsAsClassAttributes=(
treatPropertyMethodsAsClassAttributes
),
requireReturnSectionWhenReturningNothing=(
requireReturnSectionWhenReturningNothing
),
Expand All @@ -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]:
Expand All @@ -605,6 +629,9 @@ def _checkFile(
shouldDocumentPrivateClassAttributes=(
shouldDocumentPrivateClassAttributes
),
treatPropertyMethodsAsClassAttributes=(
treatPropertyMethodsAsClassAttributes
),
requireReturnSectionWhenReturningNothing=(
requireReturnSectionWhenReturningNothing
),
Expand Down
34 changes: 29 additions & 5 deletions pydoclint/utils/visitor_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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(
Expand All @@ -45,7 +48,10 @@ def checkClassAttributesAgainstClassDocstring(
shouldDocumentPrivateClassAttributes
),
)
actualArgs: ArgList = _convertClassAttributesIntoArgList(classAttributes)
actualArgs: ArgList = _convertClassAttributesIntoArgList(
classAttrs=classAttributes,
treatPropertyMethodsAsClassAttrs=treatPropertyMethodsAsClassAttributes,
)

classDocstring: str = getDocstring(node)

Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand All @@ -150,20 +164,30 @@ 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):
if isinstance(attr.targets[0], ast.Tuple):
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)
Expand Down
7 changes: 7 additions & 0 deletions pydoclint/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -77,6 +78,9 @@ def __init__(
self.shouldDocumentPrivateClassAttributes: bool = (
shouldDocumentPrivateClassAttributes
)
self.treatPropertyMethodsAsClassAttributes: bool = (
treatPropertyMethodsAsClassAttributes
)
self.requireReturnSectionWhenReturningNothing: bool = (
requireReturnSectionWhenReturningNothing
)
Expand Down Expand Up @@ -105,6 +109,9 @@ def visit_ClassDef(self, node: ast.ClassDef): # noqa: D102
shouldDocumentPrivateClassAttributes=(
self.shouldDocumentPrivateClassAttributes
),
treatPropertyMethodsAsClassAttributes=(
self.treatPropertyMethodsAsClassAttributes
),
)

self.generic_visit(node)
Expand Down
29 changes: 29 additions & 0 deletions tests/data/edge_cases/12_property_methods_as_class_attr/google.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit 675bc35

Please sign in to comment.