Skip to content

Commit 57bd7bf

Browse files
committed
Implement class checking.
1 parent 3eb9c51 commit 57bd7bf

File tree

4 files changed

+204
-71
lines changed

4 files changed

+204
-71
lines changed

flake8_params/__init__.py

Lines changed: 103 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@
3030

3131
# stdlib
3232
import ast
33-
from typing import Iterator, Union
33+
from typing import Iterator, List, Optional, Union
3434

3535
# 3rd party
3636
import flake8_helper
3737

38-
__all__ = ("Plugin", "Visitor", "get_decorator_names")
38+
__all__ = ("Plugin", "Visitor", "get_decorator_names", "check_params")
3939

4040
__author__ = "Dominic Davis-Foster"
4141
__copyright__ = "2025 Dominic Davis-Foster"
@@ -46,6 +46,7 @@
4646
PRM001 = "PRM001 Docstring parameters in wrong order."
4747
PRM002 = "PRM002 Missing parameters in docstring."
4848
PRM003 = "PRM003 Extra parameters in docstring."
49+
# TODO: class-specific codes?
4950

5051
deco_allowed_attr_names = {
5152
".setter", # Property setter
@@ -73,7 +74,7 @@ def _get_deco_name(decorator: ast.expr) -> Iterator[str]:
7374
raise NotImplementedError(decorator)
7475

7576

76-
def get_decorator_names(function: Union[ast.AsyncFunctionDef, ast.FunctionDef]) -> Iterator[str]:
77+
def get_decorator_names(function: Union[ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef]) -> Iterator[str]:
7778
"""
7879
Returns an iterator of the dotted names of decorators for the given function.
7980
@@ -84,81 +85,136 @@ def get_decorator_names(function: Union[ast.AsyncFunctionDef, ast.FunctionDef])
8485
yield from _get_deco_name(decorator)
8586

8687

88+
def check_params(
89+
signature_args: List[str],
90+
docstring_args: List[str],
91+
decorators: List[str],
92+
) -> Optional[str]:
93+
"""
94+
Check if signature and docstring parameters match, and return the flake8 error code if not.
95+
96+
:param signature_args:
97+
:param docstring_args:
98+
:param decorators: List of dotted names (e.g. ``foo.bar``, for ``@foo.bar()``) of decorators for the function or class.
99+
100+
:returns: Either a flake8 error code and description, or :py:obj:`None` if no errors were detected.
101+
"""
102+
103+
if "self" in signature_args:
104+
signature_args.remove("self")
105+
106+
if "classmethod" in decorators and signature_args:
107+
signature_args.pop(0)
108+
for deco in decorators:
109+
if any(deco.endswith(name) for name in deco_allowed_attr_names):
110+
signature_args = []
111+
break
112+
113+
if not signature_args and not docstring_args:
114+
# No args either way
115+
return None
116+
117+
if signature_args == docstring_args:
118+
# All match
119+
return None
120+
121+
# Either wrong order, extra in signature, extra in doc
122+
signature_set = set(signature_args)
123+
docstring_set = set(docstring_args)
124+
if signature_set == docstring_set:
125+
# Wrong order
126+
return PRM001
127+
elif signature_set - docstring_set:
128+
# Extras in signature
129+
return PRM002
130+
elif docstring_set - signature_set:
131+
# Extras in docstrings
132+
return PRM003
133+
134+
return None # pragma: no cover
135+
136+
87137
class Visitor(flake8_helper.Visitor):
88138
"""
89139
AST node visitor for identifying mismatches between function signatures and docstring params.
90140
"""
91141

142+
# TODO: async functions
143+
92144
def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: D102
93-
docstring = ast.get_docstring(node, clean=False)
94145
if node.name == "__init__":
95-
# TODO: special case; parameters go on class
96146
self.generic_visit(node)
97147
return
98148

149+
docstring = ast.get_docstring(node, clean=False)
150+
99151
if not docstring:
100152
self.generic_visit(node)
101153
return
102154

103-
seen_args = []
155+
docstring_args = []
104156
for line in docstring.split('\n'):
105157
line = line.strip()
106158
if line.startswith(":param"):
107-
seen_args.append(line[6:].split(':', 1)[0].strip())
159+
docstring_args.append(line[6:].split(':', 1)[0].strip())
108160

109161
signature_args = [a.arg for a in node.args.args]
110-
if "self" in signature_args:
111-
signature_args.remove("self")
112162

113-
# decorators = [n.id for n in node.decorator_list if isinstance(n, ast.Name)]
114163
decorators = list(get_decorator_names(node))
115-
if "classmethod" in decorators and signature_args:
116-
signature_args.pop(0)
117-
for deco in decorators:
118-
if any(deco.endswith(name) for name in deco_allowed_attr_names):
119-
signature_args = []
120-
break
121164

122-
if not signature_args and not seen_args:
123-
# No args either way
165+
error = check_params(signature_args, docstring_args, decorators)
166+
if not error:
167+
self.generic_visit(node)
168+
return
169+
170+
self.errors.append((
171+
node.lineno,
172+
node.col_offset,
173+
error,
174+
))
175+
176+
self.generic_visit(node)
177+
178+
def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: D102
179+
docstring = ast.get_docstring(node, clean=False)
180+
181+
if not docstring:
124182
self.generic_visit(node)
125183
return
126184

127-
if signature_args == seen_args:
128-
# All match
185+
docstring_args = []
186+
for line in docstring.split('\n'):
187+
line = line.strip()
188+
if line.startswith(":param"):
189+
docstring_args.append(line[6:].split(':', 1)[0].strip())
190+
191+
decorators = list(get_decorator_names(node))
192+
193+
signature_args = []
194+
functions_in_body: List[ast.FunctionDef] = [n for n in node.body if isinstance(n, ast.FunctionDef)]
195+
196+
for function in functions_in_body:
197+
if function.name == "__init__":
198+
signature_args = [a.arg for a in function.args.args]
199+
break
200+
else:
201+
# No __init__; maybe it comes from a base class.
202+
# TODO: check for base classes and still error if non exist
203+
return None
204+
205+
error = check_params(signature_args, docstring_args, decorators)
206+
if not error:
129207
self.generic_visit(node)
130208
return
131209

132-
# Either wrong order, extra in signature, extra in doc
133-
signature_set = set(signature_args)
134-
seen_set = set(seen_args)
135-
if signature_set == seen_set:
136-
# Wrong order
137-
self.errors.append((
138-
node.lineno,
139-
node.col_offset,
140-
PRM001,
141-
))
142-
elif signature_set - seen_set:
143-
# Extras in signature
144-
self.errors.append((
145-
node.lineno,
146-
node.col_offset,
147-
PRM002,
148-
))
149-
elif seen_set - signature_set:
150-
# Extras in docstrings
151-
self.errors.append((
152-
node.lineno,
153-
node.col_offset,
154-
PRM003,
155-
))
210+
self.errors.append((
211+
node.lineno,
212+
node.col_offset,
213+
error,
214+
))
156215

157216
self.generic_visit(node)
158217

159-
# def visit_ClassDef(self, node: ast.ClassDef):
160-
# breakpoint()
161-
162218

163219
class Plugin(flake8_helper.Plugin[Visitor]):
164220
"""

tests/example_code.py

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ def docstring_wrong_order(self, foo, bar, baz):
3636
"""
3737

3838

39-
@classmethod
40-
def is_classmethod(cls, foo, bar, baz):
39+
def missing_in_signature(foo, bar):
4140
"""
4241
Does something.
4342
@@ -47,60 +46,135 @@ def is_classmethod(cls, foo, bar, baz):
4746
"""
4847

4948

50-
@classmethod
51-
def missing_in_docstring_with_classmethod(cls, foo, bar, baz):
49+
def missing_in_signature_with_self(self, foo, bar):
5250
"""
5351
Does something.
5452
5553
:param foo:
54+
:param bar:
5655
:param baz:
5756
"""
5857

5958

60-
def missing_in_signature(foo, bar):
59+
class ClassMissingDocstring:
6160
"""
62-
Does something.
61+
A class.
6362
6463
:param foo:
6564
:param bar:
66-
:param baz:
6765
"""
6866

67+
def __init__(self, foo, bar, baz):
68+
pass
6969

70-
def missing_in_signature_with_self(self, foo, bar):
70+
@classmethod
71+
def is_classmethod(cls, foo, bar, baz):
72+
"""
73+
Does something.
74+
75+
:param foo:
76+
:param bar:
77+
:param baz:
78+
"""
79+
80+
@classmethod
81+
def missing_in_docstring_with_classmethod(cls, foo, bar, baz):
82+
"""
83+
Does something.
84+
85+
:param foo:
86+
:param baz:
87+
"""
88+
89+
@classmethod
90+
def missing_in_signature_with_classmethod(cls, foo, baz):
91+
"""
92+
Does something.
93+
94+
:param foo:
95+
:param bar:
96+
:param baz:
97+
"""
98+
99+
@property
100+
def a_property(self):
101+
"""
102+
A property.
103+
"""
104+
105+
@a_property.setter
106+
def a_property(self, val):
107+
"""
108+
A property.
109+
"""
110+
111+
112+
class ClassMissingSignature:
71113
"""
72-
Does something.
114+
A class.
73115
74116
:param foo:
75117
:param bar:
76118
:param baz:
77119
"""
78120

121+
def __init__(self, foo, bar):
122+
pass
79123

80-
@classmethod
81-
def missing_in_signature_with_classmethod(cls, foo, baz):
124+
125+
class ClassWrongOrder:
82126
"""
83-
Does something.
127+
A class.
84128
85-
:param foo:
86129
:param bar:
130+
:param foo:
87131
:param baz:
88132
"""
89133

134+
def __init__(self, foo, bar, baz):
135+
pass
136+
90137

91-
@property
92-
def a_property(self):
138+
class ClassNoInit:
93139
"""
94-
A property.
140+
A class.
141+
142+
:param foo:
143+
:param bar:
144+
:param baz:
95145
"""
96146

97147

98-
@a_property.setter
99-
def a_property(self, val):
148+
class ClassNoDocstring:
149+
150+
def __init__(self, foo, bar, baz):
151+
pass
152+
153+
154+
class ClassInitDocstring:
155+
156+
def __init__(self, foo, bar, baz):
157+
"""
158+
Setup the function.
159+
160+
:param foo:
161+
:param bar:
162+
:param baz:
163+
"""
164+
165+
166+
class GoodClass:
100167
"""
101-
A property.
168+
A class.
169+
170+
:param foo:
171+
:param bar:
172+
:param baz:
102173
"""
103174

175+
def __init__(self, foo, bar, baz):
176+
pass
177+
104178

105179
# 3rd party
106180
import click # type: ignore
Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
1:0: PRM002 Missing parameters in docstring.
22
20:0: PRM002 Missing parameters in docstring.
33
29:0: PRM001 Docstring parameters in wrong order.
4-
51:0: PRM002 Missing parameters in docstring.
5-
60:0: PRM003 Extra parameters in docstring.
6-
70:0: PRM003 Extra parameters in docstring.
7-
81:0: PRM003 Extra parameters in docstring.
4+
39:0: PRM003 Extra parameters in docstring.
5+
49:0: PRM003 Extra parameters in docstring.
6+
59:0: PRM002 Missing parameters in docstring.
7+
81:1: PRM002 Missing parameters in docstring.
8+
90:1: PRM003 Extra parameters in docstring.
9+
112:0: PRM003 Extra parameters in docstring.
10+
125:0: PRM001 Docstring parameters in wrong order.

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ rst-directives =
177177
rst-roles = choosealicense
178178
per-file-ignores =
179179
tests/*: D100 D101 D102 D103 D104 D106 D201 D204 D207 D208 D209 D210 D211 D212 D213 D214 D215 D300 D301 D400 D402 D403 D404 D415 D417 DALL000 SLOT000 SLOT001 SLOT002
180-
tests/example_code.py: PRM001 PRM002 PRM003 MAN001 MAN002 D100 D102 D103 DALL000 E402
180+
tests/example_code.py: PRM001 PRM002 PRM003 MAN001 MAN002 D100 D101 D102 D103 DALL000 E402
181181
*/*.pyi: E301 E302 E305 D100 D101 D102 D103 D104 D106 D201 D204 D207 D208 D209 D210 D211 D212 D213 D214 D215 D300 D301 D400 D402 D403 D404 D415 D417 DALL000 SLOT000 SLOT001 SLOT002
182182
pytest-parametrize-names-type = csv
183183
inline-quotes = "

0 commit comments

Comments
 (0)