Skip to content

Commit 1854472

Browse files
feat: forbid confusing syntax (WPS364); add visitor and tests (#3516)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 51a76e3 commit 1854472

File tree

6 files changed

+131
-0
lines changed

6 files changed

+131
-0
lines changed

tests/fixtures/noqa/noqa.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,3 +765,6 @@ async def test_await_in_loop():
765765
my_print('zero division error')
766766
my_print(3 / 3)
767767
my_print('no zero division error')
768+
769+
if not user in users: # noqa: WPS364
770+
my_print('legacy not-in style')

tests/test_checker/test_noqa.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
'WPS361': 0, # disabled since 1.0.0
166166
'WPS362': 2,
167167
'WPS363': 1,
168+
'WPS364': 1,
168169
'WPS400': 0, # defined in ignored violations.
169170
'WPS401': 0, # logically unacceptable.
170171
'WPS402': 0, # defined in ignored violations.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
3+
from wemake_python_styleguide.violations.consistency import (
4+
NotInWithUnaryOpViolation,
5+
)
6+
from wemake_python_styleguide.visitors.ast.compares import (
7+
NotInUnaryVisitor,
8+
)
9+
10+
11+
@pytest.mark.parametrize(
12+
'code',
13+
[
14+
'if not x in items: ...',
15+
'if not (x in items): ...',
16+
'result = not value in data',
17+
'flag = not (value in data)',
18+
'while not a in b: ...',
19+
],
20+
)
21+
def test_not_in_unary_detects(
22+
assert_errors,
23+
parse_ast_tree,
24+
default_options,
25+
code,
26+
):
27+
"""Ensures that ``not a in b`` forms are reported."""
28+
tree = parse_ast_tree(code)
29+
30+
visitor = NotInUnaryVisitor(default_options, tree=tree)
31+
visitor.run()
32+
33+
assert_errors(visitor, [NotInWithUnaryOpViolation])
34+
35+
36+
@pytest.mark.parametrize(
37+
'code',
38+
[
39+
'if x not in items: ...',
40+
'result = x not in data',
41+
'if (not x) in items: ...',
42+
'if not_called() in seq: ...',
43+
'if y in items: ...',
44+
'if not a is b: ...',
45+
'if not (a is b): ...',
46+
'if not (x in y in z): ...',
47+
],
48+
)
49+
def test_not_in_unary_ok(
50+
assert_errors,
51+
parse_ast_tree,
52+
default_options,
53+
code,
54+
):
55+
"""Ensures that correct forms are not reported."""
56+
tree = parse_ast_tree(code)
57+
58+
visitor = NotInUnaryVisitor(default_options, tree=tree)
59+
visitor.run()
60+
61+
assert_errors(visitor, [])

wemake_python_styleguide/presets/types/tree.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
compares.WrongConstantCompareVisitor,
6666
compares.InCompareSanityVisitor,
6767
compares.WrongFloatComplexCompareVisitor,
68+
compares.NotInUnaryVisitor,
6869
conditions.IfStatementVisitor,
6970
conditions.BooleanConditionVisitor,
7071
conditions.MatchVisitor,

wemake_python_styleguide/violations/consistency.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2381,3 +2381,37 @@ class RaiseSystemExitViolation(ASTViolation):
23812381

23822382
error_template = 'Found `raise SystemExit`, instead of using `sys.exit`'
23832383
code = 363
2384+
2385+
2386+
@final
2387+
class NotInWithUnaryOpViolation(ASTViolation):
2388+
"""
2389+
Forbid using ``not a in b`` instead of ``a not in b``.
2390+
2391+
Reasoning:
2392+
The expression ``not a in b`` is parsed as ``not (a in b)`` and is
2393+
equivalent to ``a not in b``. However, it is easy to misread as
2394+
``(not a) in b`` or require extra parsing effort. Using the explicit
2395+
``not in`` operator is clearer and idiomatic.
2396+
2397+
Solution:
2398+
Replace ``not a in b`` (or ``not (a in b)``) with ``a not in b``.
2399+
2400+
Example::
2401+
2402+
# Correct:
2403+
if user not in users:
2404+
...
2405+
2406+
# Wrong:
2407+
if not user in users:
2408+
...
2409+
if not (user in users):
2410+
...
2411+
2412+
.. versionadded:: 1.5.0
2413+
2414+
"""
2415+
2416+
error_template = 'Found legacy `not ... in`, use `... not in` instead'
2417+
code = 364

wemake_python_styleguide/visitors/ast/compares.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
ConstantCompareViolation,
1818
ConstantConditionViolation,
1919
MultipleInCompareViolation,
20+
NotInWithUnaryOpViolation,
2021
ReversedComplexCompareViolation,
2122
)
2223
from wemake_python_styleguide.violations.refactoring import (
@@ -237,3 +238,33 @@ def _is_float_or_complex(self, node: ast.AST) -> bool:
237238
node.value,
238239
float | complex,
239240
)
241+
242+
243+
@final
244+
class NotInUnaryVisitor(BaseNodeVisitor):
245+
"""Forbids ``not a in b`` which should be written as ``a not in b``."""
246+
247+
def visit_UnaryOp(self, node: ast.UnaryOp) -> None:
248+
"""
249+
Detects ``not (a in b)`` pattern syntactically.
250+
251+
Python parses both ``not a in b`` and ``not (a in b)`` as a
252+
``UnaryOp(Not, Compare(left, In, right))``; we catch this shape
253+
and suggest ``a not in b`` instead.
254+
"""
255+
self._check_legacy_not_in(node)
256+
self.generic_visit(node)
257+
258+
def _check_legacy_not_in(self, node: ast.UnaryOp) -> None:
259+
if not isinstance(node.op, ast.Not):
260+
return
261+
if not isinstance(node.operand, ast.Compare):
262+
return
263+
264+
comp = node.operand
265+
if len(comp.ops) != 1:
266+
return
267+
if not isinstance(comp.ops[0], ast.In):
268+
return
269+
270+
self.add_violation(NotInWithUnaryOpViolation(node))

0 commit comments

Comments
 (0)