Skip to content

Commit c6d2331

Browse files
docparams extension considers type comments as type documentation. (#6288)
Closes #6287 Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent fe56e0e commit c6d2331

12 files changed

+391
-101
lines changed

doc/whatsnew/fragments/6287.bugfix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
docparams extension considers type comments as type documentation.
2+
3+
Closes #6287

pylint/extensions/_check_docs_utils.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
from __future__ import annotations
88

9+
import itertools
910
import re
11+
from collections.abc import Iterable
1012

1113
import astroid
1214
from astroid import nodes
@@ -159,6 +161,106 @@ def possible_exc_types(node: nodes.NodeNG) -> set[nodes.ClassDef]:
159161
return set()
160162

161163

164+
def _is_ellipsis(node: nodes.NodeNG) -> bool:
165+
return isinstance(node, nodes.Const) and node.value == Ellipsis
166+
167+
168+
def _merge_annotations(
169+
annotations: Iterable[nodes.NodeNG], comment_annotations: Iterable[nodes.NodeNG]
170+
) -> Iterable[nodes.NodeNG | None]:
171+
for ann, comment_ann in itertools.zip_longest(annotations, comment_annotations):
172+
if ann and not _is_ellipsis(ann):
173+
yield ann
174+
elif comment_ann and not _is_ellipsis(comment_ann):
175+
yield comment_ann
176+
else:
177+
yield None
178+
179+
180+
def _annotations_list(args_node: nodes.Arguments) -> list[nodes.NodeNG]:
181+
"""Get a merged list of annotations.
182+
183+
The annotations can come from:
184+
185+
* Real type annotations.
186+
* A type comment on the function.
187+
* A type common on the individual argument.
188+
189+
:param args_node: The node to get the annotations for.
190+
:returns: The annotations.
191+
"""
192+
plain_annotations = args_node.annotations or ()
193+
func_comment_annotations = args_node.parent.type_comment_args or ()
194+
comment_annotations = args_node.type_comment_posonlyargs
195+
comment_annotations += args_node.type_comment_args or []
196+
comment_annotations += args_node.type_comment_kwonlyargs
197+
return list(
198+
_merge_annotations(
199+
plain_annotations,
200+
_merge_annotations(func_comment_annotations, comment_annotations),
201+
)
202+
)
203+
204+
205+
def args_with_annotation(args_node: nodes.Arguments) -> set[str]:
206+
result = set()
207+
annotations = _annotations_list(args_node)
208+
annotation_offset = 0
209+
210+
if args_node.posonlyargs:
211+
posonlyargs_annotations = args_node.posonlyargs_annotations
212+
if not any(args_node.posonlyargs_annotations):
213+
num_args = len(args_node.posonlyargs)
214+
posonlyargs_annotations = annotations[
215+
annotation_offset : annotation_offset + num_args
216+
]
217+
annotation_offset += num_args
218+
219+
for arg, annotation in zip(args_node.posonlyargs, posonlyargs_annotations):
220+
if annotation:
221+
result.add(arg.name)
222+
223+
if args_node.args:
224+
num_args = len(args_node.args)
225+
for arg, annotation in zip(
226+
args_node.args,
227+
annotations[annotation_offset : annotation_offset + num_args],
228+
):
229+
if annotation:
230+
result.add(arg.name)
231+
232+
annotation_offset += num_args
233+
234+
if args_node.vararg:
235+
if args_node.varargannotation:
236+
result.add(args_node.vararg)
237+
elif len(annotations) > annotation_offset and annotations[annotation_offset]:
238+
result.add(args_node.vararg)
239+
annotation_offset += 1
240+
241+
if args_node.kwonlyargs:
242+
kwonlyargs_annotations = args_node.kwonlyargs_annotations
243+
if not any(args_node.kwonlyargs_annotations):
244+
num_args = len(args_node.kwonlyargs)
245+
kwonlyargs_annotations = annotations[
246+
annotation_offset : annotation_offset + num_args
247+
]
248+
annotation_offset += num_args
249+
250+
for arg, annotation in zip(args_node.kwonlyargs, kwonlyargs_annotations):
251+
if annotation:
252+
result.add(arg.name)
253+
254+
if args_node.kwarg:
255+
if args_node.kwargannotation:
256+
result.add(args_node.kwarg)
257+
elif len(annotations) > annotation_offset and annotations[annotation_offset]:
258+
result.add(args_node.kwarg)
259+
annotation_offset += 1
260+
261+
return result
262+
263+
162264
def docstringify(
163265
docstring: nodes.Const | None, default_type: str = "default"
164266
) -> Docstring:

pylint/extensions/docparams.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ def visit_return(self, node: nodes.Return) -> None:
348348
if not (doc.has_returns() or (doc.has_property_returns() and is_property)):
349349
self.add_message("missing-return-doc", node=func_node, confidence=HIGH)
350350

351-
if func_node.returns:
351+
if func_node.returns or func_node.type_comment_returns:
352352
return
353353

354354
if not (doc.has_rtype() or (doc.has_property_type() and is_property)):
@@ -379,7 +379,9 @@ def visit_yield(self, node: nodes.Yield | nodes.YieldFrom) -> None:
379379
if not doc_has_yields:
380380
self.add_message("missing-yield-doc", node=func_node, confidence=HIGH)
381381

382-
if not (doc_has_yields_type or func_node.returns):
382+
if not (
383+
doc_has_yields_type or func_node.returns or func_node.type_comment_returns
384+
):
383385
self.add_message("missing-yield-type-doc", node=func_node, confidence=HIGH)
384386

385387
visit_yieldfrom = visit_yield
@@ -540,8 +542,9 @@ class constructor.
540542

541543
# Collect the function arguments.
542544
expected_argument_names = {arg.name for arg in arguments_node.args}
543-
expected_argument_names.update(arg.name for arg in arguments_node.kwonlyargs)
544-
expected_argument_names.update(arg.name for arg in arguments_node.posonlyargs)
545+
expected_argument_names.update(
546+
a.name for a in arguments_node.posonlyargs + arguments_node.kwonlyargs
547+
)
545548
not_needed_type_in_docstring = self.not_needed_param_in_docstring.copy()
546549

547550
expected_but_ignored_argument_names = set()
@@ -564,7 +567,7 @@ class constructor.
564567
if not params_with_doc and not params_with_type and accept_no_param_doc:
565568
tolerate_missing_params = True
566569

567-
# This is before the update of param_with_type because this must check only
570+
# This is before the update of params_with_type because this must check only
568571
# the type documented in a docstring, not the one using pep484
569572
# See #4117 and #4593
570573
self._compare_ignored_args(
@@ -573,15 +576,7 @@ class constructor.
573576
expected_but_ignored_argument_names,
574577
warning_node,
575578
)
576-
for index, arg_name in enumerate(arguments_node.args):
577-
if arguments_node.annotations[index]:
578-
params_with_type.add(arg_name.name)
579-
for index, arg_name in enumerate(arguments_node.kwonlyargs):
580-
if arguments_node.kwonlyargs_annotations[index]:
581-
params_with_type.add(arg_name.name)
582-
for index, arg_name in enumerate(arguments_node.posonlyargs):
583-
if arguments_node.posonlyargs_annotations[index]:
584-
params_with_type.add(arg_name.name)
579+
params_with_type |= utils.args_with_annotation(arguments_node)
585580

586581
if not tolerate_missing_params:
587582
missing_param_doc = (expected_argument_names - params_with_doc) - (

tests/functional/ext/docparams/missing_param_doc.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,70 @@ def foobar15(*args):
140140
Relevant parameters.
141141
"""
142142
print(args)
143+
144+
145+
def foobar16(one: int, two: str, three: float) -> int:
146+
"""Description of the function
147+
148+
Args:
149+
one: A number.
150+
two: Another number.
151+
three: Yes another number.
152+
153+
Returns:
154+
The number one.
155+
"""
156+
print(one, two, three)
157+
return 1
158+
159+
160+
def foobar17(one, two, three):
161+
# type: (int, str, float) -> int
162+
"""Description of the function
163+
164+
Args:
165+
one: A number.
166+
two: Another number.
167+
three: Yes another number.
168+
169+
Returns:
170+
The number one.
171+
"""
172+
print(one, two, three)
173+
return 1
174+
175+
176+
def foobar18(
177+
one, # type: int
178+
two, # type: str
179+
three, # type: float
180+
):
181+
# type: (...) -> int
182+
"""Description of the function
183+
184+
Args:
185+
one: A number.
186+
two: Another number.
187+
three: Yes another number.
188+
189+
Returns:
190+
The number one.
191+
"""
192+
print(one, two, three)
193+
return 1
194+
195+
196+
def foobar19(one, two, **kwargs):
197+
# type: (int, str, float) -> int
198+
"""Description of the function
199+
200+
Args:
201+
one: A number.
202+
two: Another number.
203+
kwargs: More numbers.
204+
205+
Returns:
206+
The number one.
207+
"""
208+
print(one, two, kwargs)
209+
return 1
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#pylint: disable= missing-module-docstring
2+
3+
def foobar1(one: int, /, two: str, *, three: float) -> int:
4+
"""Description of the function
5+
6+
Args:
7+
one: A number.
8+
two: Another number.
9+
three: Yes another number.
10+
11+
Returns:
12+
The number one.
13+
"""
14+
print(one, two, three)
15+
return 1
16+
17+
18+
def foobar2(one, /, two, * three):
19+
# type: (int, str, float) -> int
20+
"""Description of the function
21+
22+
Args:
23+
one: A number.
24+
two: Another number.
25+
three: Yes another number.
26+
27+
Returns:
28+
The number one.
29+
"""
30+
print(one, two, three)
31+
return 1
32+
33+
34+
def foobar3(
35+
one, # type: int
36+
/,
37+
two, # type: str
38+
*,
39+
three, # type: float
40+
):
41+
# type: (...) -> int
42+
"""Description of the function
43+
44+
Args:
45+
one: A number.
46+
two: Another number.
47+
three: Yes another number.
48+
49+
Returns:
50+
The number one.
51+
"""
52+
print(one, two, three)
53+
return 1
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[MASTER]
2+
load-plugins=pylint.extensions.docparams,
3+
4+
[testoptions]
5+
min_pyver=3.8
6+
7+
[PARAMETER_DOCUMENTATION]
8+
accept-no-param-doc=no
9+
accept-no-raise-doc=no
10+
accept-no-return-doc=no
11+
accept-no-yields-doc=no

tests/functional/ext/docparams/parameter/missing_param_doc_required_Google.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
Styleguide:
55
https://google.github.io/styleguide/pyguide.html#doc-function-args
66
"""
7+
78
# pylint: disable=invalid-name, unused-argument, undefined-variable
89
# pylint: disable=line-too-long, too-few-public-methods, missing-class-docstring
910
# pylint: disable=missing-function-docstring, function-redefined, inconsistent-return-statements
1011
# pylint: disable=dangerous-default-value, too-many-arguments
1112

13+
from __future__ import annotations
14+
1215

1316
def test_multi_line_parameters(param: int) -> None:
1417
"""Checks that multi line parameters lists are checked correctly
@@ -326,6 +329,20 @@ def test_finds_kwargs_without_type_google(named_arg, **kwargs):
326329
return named_arg
327330

328331

332+
def test_finds_kwargs_without_type_google(named_arg, **kwargs: dict[str, str]):
333+
"""The docstring
334+
335+
Args:
336+
named_arg (object): Returned
337+
**kwargs: Keyword arguments
338+
339+
Returns:
340+
object or None: Maybe named_arg
341+
"""
342+
if kwargs:
343+
return named_arg
344+
345+
329346
def test_finds_kwargs_without_asterisk_google(named_arg, **kwargs):
330347
"""The docstring
331348

0 commit comments

Comments
 (0)