Skip to content

Commit 92017a1

Browse files
authored
Fix comment handling in type hints (#259)
1 parent ab65f09 commit 92017a1

File tree

11 files changed

+285
-20
lines changed

11 files changed

+285
-20
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Change Log
22

3+
## [0.7.2] - 2025-09-02
4+
5+
- Fixed
6+
- A bug where false positive arg names are reported in the violation message
7+
- Added
8+
- Support for checking class attribute default values (numpy and Google
9+
styles only)
10+
- Full diff
11+
- https://github.com/jsh9/pydoclint/compare/0.7.1...0.7.2
12+
313
## [0.7.1] - 2025-09-02
414

515
- Added

pydoclint/utils/arg.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
from docstring_parser.common import DocstringAttr, DocstringParam
77

88
from pydoclint.utils.edge_case_error import EdgeCaseError
9-
from pydoclint.utils.generic import specialEqual, stripQuotes
9+
from pydoclint.utils.generic import (
10+
specialEqual,
11+
stripCommentsFromTypeHints,
12+
stripQuotes,
13+
)
1014
from pydoclint.utils.unparser_custom import unparseName
1115

1216

@@ -113,6 +117,24 @@ def fromAstArgWithMapping(
113117
# This means there is no default value, not even a "None"
114118
return Arg(name=astArg.arg, typeHint=typeHint)
115119

120+
@classmethod
121+
def fromArgWithMapping(
122+
cls,
123+
arg: 'Arg',
124+
argToDefaultMapping: dict[str, ast.expr],
125+
) -> 'Arg':
126+
"""Construct an Arg object from another Arg with its default value"""
127+
if arg.name in argToDefaultMapping:
128+
# This means there IS a default value, even if it's None
129+
defaultValue = argToDefaultMapping[arg.name]
130+
return Arg(
131+
name=arg.name,
132+
typeHint=f'{arg.typeHint}, default={unparseName(defaultValue)}',
133+
)
134+
135+
# This means there is no default value, not even a "None"
136+
return arg
137+
116138
@classmethod
117139
def fromAstAnnAssign(cls, astAnnAssign: ast.AnnAssign) -> 'Arg':
118140
"""Construct an Arg object from a Python ast.AnnAssign object"""
@@ -359,7 +381,14 @@ def findArgsWithDifferentTypeHints(self, other: 'ArgList') -> list[Arg]:
359381
for selfArg in self.infoList:
360382
selfArgTypeHint: str = selfArg.typeHint
361383
otherArgTypeHint: str = other.lookup[selfArg.name]
362-
if not specialEqual(selfArgTypeHint, otherArgTypeHint):
384+
385+
# Here we use unparseName(ast.parse(...)) in order to get rid of
386+
# the comments at the end of type hints,
387+
# such as: `int, default=42 # noqa: E501`
388+
if not specialEqual(
389+
stripCommentsFromTypeHints(selfArgTypeHint),
390+
stripCommentsFromTypeHints(otherArgTypeHint),
391+
):
363392
result.append(selfArg)
364393

365394
return result

pydoclint/utils/generic.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from pydoclint.utils.astTypes import ClassOrFunctionDef, FuncOrAsyncFuncDef
99
from pydoclint.utils.method_type import MethodType
10+
from pydoclint.utils.unparser_custom import unparseName
1011
from pydoclint.utils.violation import Violation
1112

1213
if TYPE_CHECKING:
@@ -272,7 +273,7 @@ def doList1ItemsStartWithList2Items(
272273
return True
273274

274275

275-
def buildArgToDefaultMapping(
276+
def buildFuncArgToDefaultMapping(
276277
funcDef: FuncOrAsyncFuncDef,
277278
) -> dict[ast.arg, ast.expr]:
278279
"""
@@ -315,3 +316,54 @@ def buildArgToDefaultMapping(
315316
argToDefaultMapping[kwOnlyArgs[i]] = kwDefaults[i] # type: ignore[assignment] # noqa: LN002
316317

317318
return argToDefaultMapping
319+
320+
321+
def buildClassAttrToDefaultMapping(
322+
classDef: ast.ClassDef,
323+
) -> dict[str, ast.expr]:
324+
"""
325+
Build a mapping from class attribute names to their default values using
326+
proper AST structure.
327+
328+
Parameters
329+
----------
330+
classDef : ast.ClassDef
331+
Class definition node
332+
333+
Returns
334+
-------
335+
dict[str, ast.expr]
336+
Dictionary mapping attribute names to their default values
337+
"""
338+
attrToDefaultMapping: dict[str, ast.expr] = {}
339+
340+
# Iterate through the class body to find attribute assignments
341+
for node in classDef.body:
342+
if isinstance(node, ast.AnnAssign) and isinstance(
343+
node.target, ast.Name
344+
):
345+
# This is a typed attribute assignment like: attr: int = 42
346+
if node.value is not None:
347+
attrToDefaultMapping[node.target.id] = node.value
348+
elif isinstance(node, ast.Assign):
349+
# This is a regular assignment like: attr = 42
350+
for target in node.targets:
351+
if isinstance(target, ast.Name):
352+
attrToDefaultMapping[target.id] = node.value
353+
354+
return attrToDefaultMapping
355+
356+
357+
def stripCommentsFromTypeHints(typeHint: str) -> str:
358+
"""
359+
Strip comments from type hints to enable comparison between
360+
docstring type hints and actual type hints.
361+
"""
362+
result: str
363+
try:
364+
parsed = unparseName(ast.parse(typeHint))
365+
result = parsed if parsed is not None else typeHint
366+
except SyntaxError:
367+
result = typeHint
368+
369+
return result

pydoclint/utils/visitor_helper.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydoclint.utils.edge_case_error import EdgeCaseError
1111
from pydoclint.utils.generic import (
1212
appendArgsToCheckToV105,
13+
buildClassAttrToDefaultMapping,
1314
getDocstring,
1415
specialEqual,
1516
stripQuotes,
@@ -42,6 +43,7 @@ def checkClassAttributesAgainstClassDocstring(
4243
shouldDocumentPrivateClassAttributes: bool,
4344
treatPropertyMethodsAsClassAttributes: bool,
4445
onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool,
46+
checkArgDefaults: bool,
4547
) -> None:
4648
"""Check class attribute list against the attribute list in docstring"""
4749
actualArgs: ArgList = extractClassAttributesFromNode(
@@ -53,6 +55,7 @@ def checkClassAttributesAgainstClassDocstring(
5355
onlyAttrsWithClassVarAreTreatedAsClassAttrs=(
5456
onlyAttrsWithClassVarAreTreatedAsClassAttrs
5557
),
58+
checkArgDefaults=checkArgDefaults,
5659
)
5760

5861
classDocstring: str = getDocstring(node)
@@ -126,12 +129,13 @@ def checkClassAttributesAgainstClassDocstring(
126129
)
127130

128131

129-
def extractClassAttributesFromNode(
132+
def extractClassAttributesFromNode( # noqa: C901
130133
*,
131134
node: ast.ClassDef,
132135
shouldDocumentPrivateClassAttributes: bool,
133136
treatPropertyMethodsAsClassAttrs: bool,
134137
onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool,
138+
checkArgDefaults: bool,
135139
) -> ArgList:
136140
"""
137141
Extract class attributes from an AST node.
@@ -151,6 +155,9 @@ def extractClassAttributesFromNode(
151155
within ``ClassVar`` (where ``ClassVar`` is imported from ``typing``)
152156
are treated as class attributes, and all other attributes are
153157
treated as instance attributes.
158+
checkArgDefaults : bool
159+
If True, we should extract the arguments' default values and attach
160+
them to the type hints.
154161
155162
Returns
156163
-------
@@ -201,7 +208,21 @@ def extractClassAttributesFromNode(
201208
)
202209
]
203210

204-
return ArgList(infoList=atl)
211+
astArgList = ArgList(infoList=atl)
212+
213+
if not checkArgDefaults: # no need to add defaults to type hints
214+
return astArgList
215+
216+
argToDefaultMapping: dict[str, ast.expr] = buildClassAttrToDefaultMapping(
217+
node,
218+
)
219+
220+
return ArgList(
221+
[
222+
Arg.fromArgWithMapping(_, argToDefaultMapping)
223+
for _ in astArgList.infoList
224+
]
225+
)
205226

206227

207228
def checkDocArgsLengthAgainstActualArgs(

pydoclint/visitor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pydoclint.utils.doc import Doc
1010
from pydoclint.utils.edge_case_error import EdgeCaseError
1111
from pydoclint.utils.generic import (
12-
buildArgToDefaultMapping,
12+
buildFuncArgToDefaultMapping,
1313
collectFuncArgs,
1414
detectMethodType,
1515
doList1ItemsStartWithList2Items,
@@ -153,6 +153,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: D102
153153
onlyAttrsWithClassVarAreTreatedAsClassAttrs=(
154154
self.onlyAttrsWithClassVarAreTreatedAsClassAttrs
155155
),
156+
checkArgDefaults=self.checkArgDefaults,
156157
)
157158

158159
self.generic_visit(node)
@@ -452,7 +453,7 @@ def checkArguments( # noqa: C901
452453

453454
if self.checkArgDefaults:
454455
argToDefaultMapping: dict[ast.arg, ast.expr] = (
455-
buildArgToDefaultMapping(node)
456+
buildFuncArgToDefaultMapping(node)
456457
)
457458

458459
isMethod: bool = isinstance(parent_, ast.ClassDef)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pydoclint"
7-
version = "0.7.1"
7+
version = "0.7.2"
88
description = "A Python docstring linter that checks arguments, returns, yields, and raises sections"
99
readme = "README.md"
1010
license = {text = "MIT"}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Test case for noqa comments in class attribute type hints in numpy style docstrings."""
2+
3+
4+
def regular_function(
5+
param1: str,
6+
param2: int = 42,
7+
param3: float = 3.14,
8+
param4: str = "foo",
9+
) -> bool:
10+
"""A regular function with parameters.
11+
12+
Parameters
13+
----------
14+
param1 : str # noqa: E501
15+
First parameter with noqa comment.
16+
param2 : int, default=42#noqa:F401
17+
Second parameter with no-space noqa comment.
18+
param3 : float, default=3.14
19+
Third parameter with no-space noqa comment.
20+
param4 : str, default='foobar' # noqa: E501
21+
Fourth parameter with no-space noqa comment.
22+
23+
Returns
24+
-------
25+
bool
26+
The result.
27+
"""
28+
return True
29+
30+
31+
class TestClass:
32+
"""Class with attributes that have noqa comments in type hints.
33+
34+
Attributes
35+
----------
36+
attr1 : str # noqa: E501
37+
Regular noqa comment in type hint.
38+
attr2 : int, default=42#noqa:E501
39+
No-space noqa comment in type hint.
40+
attr3 : float, default=3.14 # NOQA : E501
41+
Spaced and uppercase noqa comment.
42+
attr4 : bool, default=True # noqa: E501,W503
43+
Multiple rule codes in noqa.
44+
attr5 : list, default=[] # this is not noqa
45+
Regular comment that should remain.
46+
attr6 : dict, default={} # noqa: F401
47+
Complex type with noqa comment.
48+
"""
49+
50+
attr1: str
51+
attr2: int = 42
52+
attr3: float = 3.14
53+
attr4: bool = True
54+
attr5: list = []
55+
attr6: dict = {}

tests/test_edge_cases.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,19 @@
575575
'section types: Generator123[typing.Any]'
576576
],
577577
),
578+
(
579+
'30_comments_in_type_hints/numpy.py',
580+
{
581+
'style': 'numpy',
582+
'checkClassAttributes': True,
583+
'checkArgDefaults': True,
584+
},
585+
[
586+
'DOC105: Function `regular_function`: Argument names match, but type hints in '
587+
'these args do not match: param4 . (Note: docstring arg defaults should look '
588+
'like: `, default=XXX`)'
589+
],
590+
),
578591
],
579592
)
580593
def testEdgeCases(

0 commit comments

Comments
 (0)