Skip to content

Commit

Permalink
Check class attributes against docstring
Browse files Browse the repository at this point in the history
  • Loading branch information
jsh9 committed Jun 23, 2024
1 parent 2b0f1d8 commit 9fa7736
Show file tree
Hide file tree
Showing 13 changed files with 819 additions and 93 deletions.
17 changes: 17 additions & 0 deletions pydoclint/flake8_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ def add_options(cls, parser): # noqa: D102
' signature do not need to appear in the docstring.'
),
)
parser.add_option(
'-cca',
'--check-class-attributes',
action='store',
default='True',
parse_from_config=True,
help=(
'If True, class attributes (the ones defined right beneath'
' "class MyClass:") are checked against the docstring.'
),
)

@classmethod
def parse_options(cls, options): # noqa: D102
Expand All @@ -181,6 +192,7 @@ def parse_options(cls, options): # noqa: D102
cls.check_return_types = options.check_return_types
cls.check_yield_types = options.check_yield_types
cls.ignore_underscore_args = options.ignore_underscore_args
cls.check_class_attributes = options.check_class_attributes
cls.style = options.style

def run(self) -> Generator[Tuple[int, int, str, Any], None, None]:
Expand Down Expand Up @@ -246,6 +258,10 @@ def run(self) -> Generator[Tuple[int, int, str, Any], None, None]:
'--ignore-underscore-args',
self.ignore_underscore_args,
)
checkClassAttributes = self._bool(
'--check-class-attributes',
self.check_class_attributes,
)

if self.style not in {'numpy', 'google', 'sphinx'}:
raise ValueError(
Expand All @@ -268,6 +284,7 @@ def run(self) -> Generator[Tuple[int, int, str, Any], None, None]:
checkReturnTypes=checkReturnTypes,
checkYieldTypes=checkYieldTypes,
ignoreUnderscoreArgs=ignoreUnderscoreArgs,
checkClassAttributes=checkClassAttributes,
style=self.style,
)
v.visit(self._tree)
Expand Down
17 changes: 17 additions & 0 deletions pydoclint/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ def validateStyleValue(
' signature do not need to appear in the docstring.'
),
)
@click.option(
'-cca',
'--check-class-attributes',
type=bool,
show_default=True,
default=True,
help=(
'If True, class attributes (the ones defined right beneath'
' "class MyClass:") are checked against the docstring.'
),
)
@click.option(
'--baseline',
type=click.Path(
Expand Down Expand Up @@ -279,6 +290,7 @@ def main( # noqa: C901
check_return_types: bool,
check_yield_types: bool,
ignore_underscore_args: bool,
check_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 @@ -363,6 +375,7 @@ def main( # noqa: C901
checkReturnTypes=check_return_types,
checkYieldTypes=check_yield_types,
ignoreUnderscoreArgs=ignore_underscore_args,
checkClassAttributes=check_class_attributes,
requireReturnSectionWhenReturningNothing=(
require_return_section_when_returning_nothing
),
Expand Down Expand Up @@ -477,6 +490,7 @@ def _checkPaths(
checkReturnTypes: bool = True,
checkYieldTypes: bool = True,
ignoreUnderscoreArgs: bool = True,
checkClassAttributes: bool = True,
requireReturnSectionWhenReturningNothing: bool = False,
requireYieldSectionWhenYieldingNothing: bool = False,
quiet: bool = False,
Expand Down Expand Up @@ -522,6 +536,7 @@ def _checkPaths(
checkReturnTypes=checkReturnTypes,
checkYieldTypes=checkYieldTypes,
ignoreUnderscoreArgs=ignoreUnderscoreArgs,
checkClassAttributes=checkClassAttributes,
requireReturnSectionWhenReturningNothing=(
requireReturnSectionWhenReturningNothing
),
Expand All @@ -546,6 +561,7 @@ def _checkFile(
checkReturnTypes: bool = True,
checkYieldTypes: bool = True,
ignoreUnderscoreArgs: bool = True,
checkClassAttributes: bool = True,
requireReturnSectionWhenReturningNothing: bool = False,
requireYieldSectionWhenYieldingNothing: bool = False,
) -> List[Violation]:
Expand All @@ -564,6 +580,7 @@ def _checkFile(
checkReturnTypes=checkReturnTypes,
checkYieldTypes=checkYieldTypes,
ignoreUnderscoreArgs=ignoreUnderscoreArgs,
checkClassAttributes=checkClassAttributes,
requireReturnSectionWhenReturningNothing=(
requireReturnSectionWhenReturningNothing
),
Expand Down
70 changes: 68 additions & 2 deletions pydoclint/utils/arg.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ast
from typing import List, Optional, Set

from docstring_parser.common import DocstringParam
from docstring_parser.common import DocstringAttr, DocstringParam

from pydoclint.utils.annotation import unparseAnnotation
from pydoclint.utils.generic import stripQuotes
Expand Down Expand Up @@ -73,16 +73,43 @@ def notStarArg(self) -> bool:

@classmethod
def fromDocstringParam(cls, param: DocstringParam) -> 'Arg':
"""Construct an Arg object from a GoogleParser Parameter object"""
"""Construct an Arg object from a DocstringParam object"""
return Arg(name=param.arg_name, typeHint=cls._str(param.type_name))

@classmethod
def fromDocstringAttr(cls, attr: DocstringAttr) -> 'Arg':
"""Construct an Arg object from a DocstringAttr object"""
return Arg(name=attr.arg_name, typeHint=cls._str(attr.type_name))

@classmethod
def fromAstArg(cls, astArg: ast.arg) -> 'Arg':
"""Construct an Arg object from a Python AST argument object"""
anno = astArg.annotation
typeHint: str = '' if anno is None else unparseAnnotation(anno)
return Arg(name=astArg.arg, typeHint=typeHint)

@classmethod
def fromAstAssignWithNonTupleTarget(cls, astAssign: ast.Assign) -> 'Arg':
if len(astAssign.targets) != 1:
raise InternalError(
f'astAssign.targets has length {len(astAssign.targets)}'
)

if not isinstance(astAssign.targets[0], ast.Name): # not a tuple
raise InternalError(
f'astAssign.targets[0] is of type {type(astAssign.targets[0])}'
' instead of ast.Name'
)

return Arg(name=astAssign.targets[0].id, typeHint='')

@classmethod
def fromAstAnnAssign(cls, astAnnAssign: ast.AnnAssign) -> 'Arg':
return Arg(
name=astAnnAssign.target.id,
typeHint=astAnnAssign.annotation.id,
)

@classmethod
def _str(cls, typeName: Optional[str]) -> str:
return '' if typeName is None else typeName
Expand Down Expand Up @@ -174,6 +201,45 @@ def fromDocstringParam(cls, params: List[DocstringParam]) -> 'ArgList':
]
return ArgList(infoList=infoList)

@classmethod
def fromDocstringAttr(
cls,
params: List[DocstringAttr],
) -> 'ArgList':
"""Construct an ArgList from a list of DocstringAttr objects"""
infoList = [
Arg.fromDocstringAttr(_)
for _ in params
# we only need 'attribute' not 'param':
if _.args[0] in {'attribute', 'attr'}
]
return ArgList(infoList=infoList)

@classmethod
def fromAstAssignWithTupleTarget(cls, astAssign: ast.Assign) -> 'ArgList':
if len(astAssign.targets) != 1:
raise InternalError(
f'astAssign.targets has length {len(astAssign.targets)}'
)

if not isinstance(astAssign.targets[0], ast.Tuple):
raise InternalError(
f'astAssign.targets[0] is of type {type(astAssign.targets[0])}'
' instead of ast.Tuple'
)

infoList = []
for i, item in enumerate(astAssign.targets[0].elts):
if not isinstance(item, ast.Name):
raise InternalError(
f'astAssign.targets[0].elts[{i}] is of type {type(item)}'
' instead of ast.Name'
)

infoList.append(Arg(name=item.id, typeHint=''))

return ArgList(infoList=infoList)

def contains(self, arg: Arg) -> bool:
"""Whether a given `Arg` object exists in the list"""
return arg.name in self.lookup
Expand Down
12 changes: 10 additions & 2 deletions pydoclint/utils/doc.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from typing import Any, List

import docstring_parser.parser as sphinx_parser
from docstring_parser.common import (
Docstring,
DocstringReturns,
DocstringYields,
)
from docstring_parser.google import GoogleParser
from docstring_parser.numpydoc import NumpydocParser
from docstring_parser.rest import parse as parseSphinx

from pydoclint.utils.arg import ArgList
from pydoclint.utils.internal_error import InternalError
Expand All @@ -29,7 +29,7 @@ def __init__(self, docstring: str, style: str = 'numpy') -> None:
parser = GoogleParser()
self.parsed = parser.parse(docstring)
elif style == 'sphinx':
self.parsed = sphinx_parser.parse(docstring)
self.parsed = parseSphinx(docstring)
else:
self._raiseException()

Expand Down Expand Up @@ -62,6 +62,14 @@ def argList(self) -> ArgList:

self._raiseException() # noqa: R503

@property
def attrList(self) -> ArgList:
"""The attributes info in the docstring, presented as an ArgList"""
if self.style in {'google', 'numpy', 'sphinx'}:
return ArgList.fromDocstringAttr(self.parsed.attrs)

self._raiseException() # noqa: R503

@property
def hasReturnsSection(self) -> bool:
"""Whether the docstring has a 'Returns' section"""
Expand Down
27 changes: 24 additions & 3 deletions pydoclint/utils/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,34 @@ def getDocstring(node: ClassOrFunctionDef) -> str:
return '' if docstring_ is None else docstring_


def generateMsgPrefix(
def generateClassMsgPrefix(node: ast.ClassDef, appendColon: bool) -> str:
"""
Generate violation message prefix for classes.
Parameters
----------
node : ast.ClassDef
The current node.
appendColon : bool
Whether to append a colon (':') at the end of the message prefix
Returns
-------
str
The violation message prefix
"""
selfName: str = getNodeName(node)
colon = ':' if appendColon else ''
return f'Class `{selfName}`{colon}'


def generateFuncMsgPrefix(
node: FuncOrAsyncFuncDef,
parent: ast.AST,
appendColon: bool,
) -> str:
"""
Generate violation message prefix.
Generate violation message prefix for function def.
Parameters
----------
Expand Down Expand Up @@ -184,7 +205,7 @@ def appendArgsToCheckToV105(
funcArgs: 'ArgList', # noqa: F821
docArgs: 'ArgList', # noqa: F821
) -> Violation:
"""Append the arg names to check to the error message of v105"""
"""Append the arg names to check to the error message of v105 or v605"""
argsToCheck: List['Arg'] = funcArgs.findArgsWithDifferentTypeHints(docArgs) # noqa: F821
argNames: str = ', '.join(_.name for _ in argsToCheck)
return original_v105.appendMoreMsg(moreMsg=argNames)
Expand Down
9 changes: 9 additions & 0 deletions pydoclint/utils/violation.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@

501: 'has "raise" statements, but the docstring does not have a "Raises" section',
502: 'has a "Raises" section in the docstring, but there are not "raise" statements in the body',

601: 'Class docstring contains fewer class attributes than actual class attributes.',
602: 'Class docstring contains more class attributes than in actual class attributes.',
603: ( # noqa: PAR001
'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 ).'
),
604: 'Attributes are the same in docstring and class def, but are in a different order.',
605: 'Attribute names match, but type hints in these attributes do not match:',
})


Expand Down
Loading

0 comments on commit 9fa7736

Please sign in to comment.