Skip to content

Commit ff6afda

Browse files
author
Lukasz Langa
committed
Introduce B902: enforce use of self and cls as first arguments in methods
1 parent df48ad1 commit ff6afda

4 files changed

Lines changed: 192 additions & 33 deletions

File tree

README.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ Users coming from Python 2 may expect the old behavior which might lead
107107
to bugs. Use native ``async def`` coroutines or mark intentional
108108
``return x`` usage with ``# noqa`` on the same line.
109109

110+
**B902**: Invalid first argument used for method. Use ``self`` for
111+
instance methods, and `cls` for class methods (which includes `__new__`
112+
and `__init_subclass__`).
113+
110114
**B950**: Line too long. This is a pragmatic equivalent of ``pycodestyle``'s
111115
E501: it considers "max-line-length" but only triggers when the value has been
112116
exceeded by **more than 10%**. You will no longer be forced to reformat code
@@ -184,6 +188,13 @@ MIT
184188
Change Log
185189
----------
186190

191+
17.2.0
192+
~~~~~~
193+
194+
* introduced B902
195+
196+
* bugfix: opinionated warnings no longer invisible in Syntastic
197+
187198
16.12.2
188199
~~~~~~~
189200

bugbear.py

Lines changed: 114 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -221,38 +221,9 @@ def visit_For(self, node):
221221
self.generic_visit(node)
222222

223223
def visit_FunctionDef(self, node):
224-
xs = list(node.body)
225-
has_yield = False
226-
return_node = None
227-
while xs:
228-
x = xs.pop()
229-
if isinstance(x, (ast.AsyncFunctionDef, ast.FunctionDef)):
230-
continue
231-
elif isinstance(x, (ast.Yield, ast.YieldFrom)):
232-
has_yield = True
233-
elif isinstance(x, ast.Return) and x.value is not None:
234-
return_node = x
235-
236-
if has_yield and return_node is not None:
237-
self.errors.append(
238-
B901(return_node.lineno, return_node.col_offset)
239-
)
240-
break
241-
242-
xs.extend(ast.iter_child_nodes(x))
243-
244-
for default in node.args.defaults:
245-
if isinstance(default, B006.mutable_literals):
246-
self.errors.append(
247-
B006(default.lineno, default.col_offset)
248-
)
249-
elif isinstance(default, ast.Call):
250-
call_path = '.'.join(self.compose_call_path(default.func))
251-
if call_path in B006.mutable_calls:
252-
self.errors.append(
253-
B006(default.lineno, default.col_offset)
254-
)
255-
224+
self.check_for_b901(node)
225+
self.check_for_b902(node)
226+
self.check_for_b006(node)
256227
self.generic_visit(node)
257228

258229
def compose_call_path(self, node):
@@ -284,6 +255,19 @@ def check_for_b005(self, node):
284255
B005(node.lineno, node.col_offset)
285256
)
286257

258+
def check_for_b006(self, node):
259+
for default in node.args.defaults:
260+
if isinstance(default, B006.mutable_literals):
261+
self.errors.append(
262+
B006(default.lineno, default.col_offset)
263+
)
264+
elif isinstance(default, ast.Call):
265+
call_path = '.'.join(self.compose_call_path(default.func))
266+
if call_path in B006.mutable_calls:
267+
self.errors.append(
268+
B006(default.lineno, default.col_offset)
269+
)
270+
287271
def check_for_b007(self, node):
288272
targets = NameFinder()
289273
targets.visit(node.target)
@@ -296,6 +280,86 @@ def check_for_b007(self, node):
296280
n = targets.names[name][0]
297281
self.errors.append(B007(n.lineno, n.col_offset, vars=(name,)))
298282

283+
def check_for_b901(self, node):
284+
xs = list(node.body)
285+
has_yield = False
286+
return_node = None
287+
while xs:
288+
x = xs.pop()
289+
if isinstance(x, (ast.AsyncFunctionDef, ast.FunctionDef)):
290+
continue
291+
elif isinstance(x, (ast.Yield, ast.YieldFrom)):
292+
has_yield = True
293+
elif isinstance(x, ast.Return) and x.value is not None:
294+
return_node = x
295+
296+
if has_yield and return_node is not None:
297+
self.errors.append(
298+
B901(return_node.lineno, return_node.col_offset)
299+
)
300+
break
301+
302+
xs.extend(ast.iter_child_nodes(x))
303+
304+
def check_for_b902(self, node):
305+
if not isinstance(self.node_stack[-2], ast.ClassDef):
306+
return
307+
308+
decorators = NameFinder()
309+
decorators.visit(node.decorator_list)
310+
311+
if 'staticmethod' in decorators.names:
312+
# TODO: maybe warn if the first argument is surprisingly `self` or
313+
# `cls`?
314+
return
315+
316+
if (
317+
'classmethod' in decorators.names or
318+
node.name in B902.implicit_classmethods
319+
):
320+
expected_first_args = B902.cls
321+
kind = 'class'
322+
else:
323+
expected_first_args = B902.self
324+
kind = 'instance'
325+
326+
args = node.args.args
327+
vararg = node.args.vararg
328+
kwarg = node.args.kwarg
329+
kwonlyargs = node.args.kwonlyargs
330+
331+
if args:
332+
actual_first_arg = args[0].arg
333+
lineno = args[0].lineno
334+
col = args[0].col_offset
335+
elif vararg:
336+
actual_first_arg = '*' + vararg.arg
337+
lineno = vararg.lineno
338+
col = vararg.col_offset
339+
elif kwarg:
340+
actual_first_arg = '**' + kwarg.arg
341+
lineno = kwarg.lineno
342+
col = kwarg.col_offset
343+
elif kwonlyargs:
344+
actual_first_arg = '*, ' + kwonlyargs[0].arg
345+
lineno = kwonlyargs[0].lineno
346+
col = kwonlyargs[0].col_offset
347+
else:
348+
actual_first_arg = '(none)'
349+
lineno = node.lineno
350+
col = node.col_offset
351+
352+
if actual_first_arg not in expected_first_args:
353+
if not actual_first_arg.startswith(('(', '*')):
354+
actual_first_arg = repr(actual_first_arg)
355+
self.errors.append(
356+
B902(
357+
lineno,
358+
col,
359+
vars=(actual_first_arg, kind, expected_first_args[0])
360+
)
361+
)
362+
299363

300364
@attr.s
301365
class NameFinder(ast.NodeVisitor):
@@ -309,6 +373,15 @@ class NameFinder(ast.NodeVisitor):
309373
def visit_Name(self, node):
310374
self.names.setdefault(node.id, []).append(node)
311375

376+
def visit(self, node):
377+
"""Like super-visit but supports iteration over lists."""
378+
if not isinstance(node, list):
379+
return super().visit(node)
380+
381+
for elem in node:
382+
super().visit(elem)
383+
return node
384+
312385

313386
error = namedtuple('error', 'lineno col message type vars')
314387
Error = partial(partial, error, type=BugBearChecker, vars=())
@@ -425,8 +498,16 @@ def visit_Name(self, node):
425498
"`async def` coroutines or put a `# noqa` comment on this "
426499
"line if this was intentional.",
427500
)
501+
B902 = Error(
502+
message="B902 Invalid first argument {} used for {} method. Use the "
503+
"canonical first argument name in methods, i.e. {}."
504+
)
505+
B902.implicit_classmethods = {'__new__', '__init_subclass__'}
506+
B902.self = ['self'] # it's a list because the first is preferred
507+
B902.cls = ['cls', 'klass'] # ditto.
508+
428509
B950 = Error(
429510
message='B950 line too long ({} > {} characters)',
430511
)
431512

432-
disabled_by_default = ["B901", "B950"]
513+
disabled_by_default = ["B901", "B902", "B950"]

tests/b902.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
def not_a_method(arg1):
2+
...
3+
4+
5+
class NoWarnings:
6+
def __init__(self):
7+
def not_a_method_either(arg1):
8+
...
9+
10+
def __new__(cls, *args, **kwargs):
11+
...
12+
13+
def method(self, arg1, *, yeah):
14+
...
15+
16+
@classmethod
17+
def someclassmethod(cls, arg1, with_default=None):
18+
...
19+
20+
@staticmethod
21+
def not_a_problem(arg1):
22+
...
23+
24+
25+
class Warnings:
26+
def __init__(i_am_special):
27+
...
28+
29+
def almost_a_class_method(cls, arg1):
30+
...
31+
32+
def almost_a_static_method():
33+
...
34+
35+
@classmethod
36+
def wat(self, i_like_confusing_people):
37+
...
38+
39+
def i_am_strange(*args, **kwargs):
40+
self = args[0]
41+
42+
def defaults_anyone(self=None):
43+
...
44+
45+
def invalid_kwargs_only(**kwargs):
46+
...
47+
48+
def invalid_keyword_only(*, self):
49+
...

tests/test_bugbear.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
B305,
1919
B306,
2020
B901,
21+
B902,
2122
B950,
2223
)
2324

@@ -131,6 +132,23 @@ def test_b901(self):
131132
self.errors(B901(8, 8), B901(35, 4))
132133
)
133134

135+
def test_b902(self):
136+
filename = Path(__file__).absolute().parent / 'b902.py'
137+
bbc = BugBearChecker(filename=str(filename))
138+
errors = list(bbc.run())
139+
self.assertEqual(
140+
errors,
141+
self.errors(
142+
B902(26, 17, vars=("'i_am_special'", 'instance', 'self')),
143+
B902(29, 30, vars=("'cls'", 'instance', 'self')),
144+
B902(32, 4, vars=("(none)", 'instance', 'self',)),
145+
B902(36, 12, vars=("'self'", 'class', 'cls')),
146+
B902(39, 22, vars=("*args", 'instance', 'self')),
147+
B902(45, 30, vars=("**kwargs", 'instance', 'self')),
148+
B902(48, 32, vars=("*, self", 'instance', 'self')),
149+
)
150+
)
151+
134152
def test_b950(self):
135153
filename = Path(__file__).absolute().parent / 'b950.py'
136154
bbc = BugBearChecker(filename=str(filename))

0 commit comments

Comments
 (0)