Skip to content

Commit 4dc0d6d

Browse files
milsskyAlexey Potapov
andauthored
fix: leaking for loops, refs wemake-services#2340 (wemake-services#3532)
Co-authored-by: Alexey Potapov <[email protected] >
1 parent c51abf3 commit 4dc0d6d

File tree

11 files changed

+354
-10
lines changed

11 files changed

+354
-10
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ Semantic versioning in our case means:
1717
change the client facing API, change code conventions significantly, etc.
1818

1919

20+
## WIP
21+
22+
### Features
23+
24+
- Adds `WPS481`: for statement not allowed in class and module scopes, #3520
25+
26+
2027
## 1.5.0
2128

2229
### Features

tests/fixtures/noqa/noqa.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ def function_with_wrong_yield():
317317
yield # noqa: WPS325
318318
yield 1
319319

320-
for literal in bad_concatenation: # noqa: WPS327, WPS328
320+
for literal in bad_concatenation: # noqa: WPS327, WPS328, WPS481
321321
continue
322322

323323
with open(bad_concatenation): # noqa: WPS328
@@ -326,7 +326,7 @@ def function_with_wrong_yield():
326326

327327
my_print(biggesst > middle >= smallest) # noqa: WPS334
328328

329-
for index in [1, 2]: # noqa: WPS335
329+
for index in [1, 2]: # noqa: WPS335, WPS481
330330
my_print(index)
331331

332332
string_concat = 'a' + 'b' # noqa: WPS336
@@ -339,11 +339,11 @@ def function_with_wrong_yield():
339339
my_print(lambda: 0) # noqa: WPS522
340340
xterm += xterm + 1 # noqa: WPS524
341341

342-
for range_len in range(len(file_obj)): # noqa: WPS518
342+
for range_len in range(len(file_obj)): # noqa: WPS518, WPS481
343343
my_print(range_len)
344344

345345
sum_container = 0
346-
for sum_item in file_obj: # noqa: WPS519
346+
for sum_item in file_obj: # noqa: WPS519, WPS481
347347
sum_container += sum_item
348348

349349
my_print(sum_container == []) # noqa: WPS520
@@ -364,7 +364,7 @@ def __init__(self) -> None:
364364
self.second = 2 # noqa: WPS601
365365

366366

367-
for symbol in 'abc': # noqa: WPS500
367+
for symbol in 'abc': # noqa: WPS500, WPS481
368368
...
369369
else:
370370
...
@@ -386,7 +386,7 @@ def __eq__(self, object_: object) -> bool: # noqa: WPS612
386386
return super().__eq__(object_)
387387

388388

389-
for loop_index in range(6): # noqa: WPS426
389+
for loop_index in range(6): # noqa: WPS426, WPS481
390390
my_print(lambda: loop_index)
391391

392392

@@ -409,7 +409,7 @@ class MyBadException(BaseException): # noqa: WPS418
409409
class ClassWithWrongContents((lambda: object)()): # noqa: WPS606
410410
__slots__ = ['a', 'a'] # noqa: WPS607
411411

412-
for bad_body_node in range(1): # noqa: WPS604
412+
for bad_body_node in range(1): # noqa: WPS481, WPS604
413413
anti_wps604 = 1
414414

415415
def method_with_no_args(): # noqa: WPS605
@@ -424,7 +424,7 @@ def bad_default_values(
424424
return True
425425

426426

427-
for nodes[0] in (1, 2, 3): # noqa: WPS405
427+
for nodes[0] in (1, 2, 3): # noqa: WPS405, WPS481
428428
...
429429

430430
with open('some') as MyBadException.custom: # noqa: WPS406
@@ -631,7 +631,7 @@ def many_raises_function(parameter): # noqa: WPS238
631631
def get_item(): # noqa: WPS463
632632
return # noqa: WPS324
633633

634-
for _, something in enumerate(collection): # noqa: WPS468
634+
for _, something in enumerate(collection): # noqa: WPS468, WPS481
635635
report(something)
636636

637637
variable_to_store_things = {
@@ -729,7 +729,7 @@ def wrong_comprehension2():
729729
]
730730

731731

732-
for unique_element in range(10):
732+
for unique_element in range(10): # noqa: WPS481
733733
if (other := unique_element) > 5: # noqa: WPS332
734734
my_print(1)
735735

tests/test_checker/test_noqa.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@
248248
'WPS478': 1,
249249
'WPS479': 0,
250250
'WPS480': 0, # only triggers on 3.12+
251+
'WPS481': 10,
251252
'WPS500': 1,
252253
'WPS501': 1,
253254
'WPS502': 0, # disabled since 1.0.0
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import pytest
2+
3+
from wemake_python_styleguide.violations.best_practices import (
4+
LeakingForLoopViolation,
5+
)
6+
from wemake_python_styleguide.visitors.ast.conditions import (
7+
LeakingForLoopVisitor,
8+
)
9+
10+
# Wrong:
11+
12+
module_scope_code = """
13+
for index in range(10):
14+
...
15+
"""
16+
17+
class_body_code = """
18+
class ClassWithBody:
19+
for index in range(10):
20+
...
21+
"""
22+
23+
for_with_wrong_del = """
24+
for index in range(10):
25+
...
26+
del other
27+
"""
28+
29+
for_unpacking_with_partial_del = """
30+
for a, b in [(1, 2)]:
31+
...
32+
del a
33+
"""
34+
35+
separate_classes_false_cleanup = """
36+
class ClassWithFor:
37+
for index in range(10):
38+
...
39+
40+
class ClassWithDel:
41+
index = ...
42+
del index
43+
"""
44+
45+
multiple_for_mixed = """
46+
for good in range(5):
47+
...
48+
del good
49+
50+
for bad in range(5):
51+
...
52+
"""
53+
54+
for_unpacking_with_unrelated_del = """
55+
for a, b in [(1, 2)]:
56+
...
57+
58+
def func(a, b):
59+
del a, b
60+
"""
61+
62+
63+
class_for_with_del_inside_method = """
64+
class ClassWithFor:
65+
for a in range(10):
66+
...
67+
68+
def cleanup(self):
69+
del a
70+
"""
71+
72+
73+
# Correct
74+
75+
for_with_del_module = """
76+
for index in range(10):
77+
...
78+
del index
79+
"""
80+
81+
for_with_del_class = """
82+
class ClassWithDel:
83+
for index in range(10):
84+
...
85+
del index
86+
"""
87+
88+
for_unpacking_with_del = """
89+
for a, b in [(1, 2)]:
90+
...
91+
del a, b
92+
"""
93+
94+
for_with_del_inside_if = """
95+
for i in range(10):
96+
...
97+
98+
if True:
99+
del i
100+
"""
101+
102+
for_unpacking_with_nested_del = """
103+
for a, b in [(1, 2)]:
104+
...
105+
106+
try:
107+
del a, b
108+
except ...:
109+
...
110+
"""
111+
112+
class_unpacking_with_nested_del = """
113+
class ClassWithNestedDel:
114+
for a, b in [(1, 2)]:
115+
...
116+
117+
try:
118+
del a, b
119+
except ...:
120+
...
121+
"""
122+
123+
124+
@pytest.mark.parametrize(
125+
'code',
126+
[
127+
module_scope_code,
128+
class_body_code,
129+
for_with_wrong_del,
130+
for_unpacking_with_partial_del,
131+
separate_classes_false_cleanup,
132+
multiple_for_mixed,
133+
for_unpacking_with_unrelated_del,
134+
class_for_with_del_inside_method,
135+
],
136+
)
137+
def test_leaking_for_loop_violation(
138+
assert_errors,
139+
parse_ast_tree,
140+
default_options,
141+
code,
142+
):
143+
"""Ensure leaking for loops without proper cleanup raise a violation."""
144+
tree = parse_ast_tree(code)
145+
146+
visitor = LeakingForLoopVisitor(default_options, tree)
147+
visitor.run()
148+
149+
assert_errors(visitor, [LeakingForLoopViolation])
150+
151+
152+
@pytest.mark.parametrize(
153+
'code',
154+
[
155+
for_with_del_module,
156+
for_with_del_class,
157+
for_unpacking_with_del,
158+
for_with_del_inside_if,
159+
for_unpacking_with_nested_del,
160+
class_unpacking_with_nested_del,
161+
],
162+
)
163+
def test_for_loop_with_del_no_violation(
164+
assert_errors,
165+
parse_ast_tree,
166+
default_options,
167+
code,
168+
):
169+
"""Ensure `for` loops with proper `del` do not raise a violation."""
170+
tree = parse_ast_tree(code)
171+
172+
visitor = LeakingForLoopVisitor(default_options, tree)
173+
visitor.run()
174+
175+
assert_errors(visitor, [])
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from wemake_python_styleguide.logic.walk.deletion import (
2+
are_variables_deleted as are_variables_deleted,
3+
)
4+
from wemake_python_styleguide.logic.walk.deletion import (
5+
extract_deleted_names as extract_deleted_names,
6+
)
7+
from wemake_python_styleguide.logic.walk.targets import (
8+
extract_names_from_targets as extract_names_from_targets,
9+
)
10+
from wemake_python_styleguide.logic.walk.targets import (
11+
get_names_from_target as get_names_from_target,
12+
)
13+
from wemake_python_styleguide.logic.walk.tree import (
14+
get_closest_parent as get_closest_parent,
15+
)
16+
from wemake_python_styleguide.logic.walk.tree import (
17+
get_subnodes_by_type as get_subnodes_by_type,
18+
)
19+
from wemake_python_styleguide.logic.walk.tree import (
20+
is_contained as is_contained,
21+
)
22+
from wemake_python_styleguide.logic.walk.tree import (
23+
is_contained_by as is_contained_by,
24+
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import ast
2+
3+
from wemake_python_styleguide.logic.nodes import get_context
4+
from wemake_python_styleguide.logic.walk.targets import (
5+
extract_names_from_targets,
6+
)
7+
from wemake_python_styleguide.types import ContextNodes
8+
9+
10+
def extract_deleted_names(
11+
node: ast.AST, *, context: ContextNodes | None = None
12+
) -> set[str]:
13+
"""Extract all variable names deleted in the given AST node."""
14+
deleted: set[str] = set()
15+
16+
for subnode in ast.walk(node):
17+
if not isinstance(subnode, ast.Delete):
18+
continue
19+
20+
if context is not None and get_context(subnode) != context:
21+
continue
22+
23+
deleted.update(extract_names_from_targets(subnode.targets))
24+
return deleted
25+
26+
27+
def are_variables_deleted(
28+
variables: set[str],
29+
body: list[ast.stmt],
30+
*,
31+
context: ContextNodes | None = None,
32+
) -> bool:
33+
"""Checks that given variables are deleted somewhere in the body."""
34+
deleted: set[str] = set()
35+
for stmt in body:
36+
deleted.update(extract_deleted_names(stmt, context=context))
37+
return variables.issubset(deleted)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import ast
2+
3+
4+
def extract_names_from_targets(targets: list[ast.expr]) -> set[str]:
5+
"""Extract names from delete statement targets."""
6+
names: set[str] = set()
7+
for target in targets:
8+
if isinstance(target, ast.Name):
9+
names.add(target.id)
10+
return names
11+
12+
13+
def get_names_from_target(target: ast.expr) -> set[str]:
14+
"""
15+
Extracts all variable names from a target expression.
16+
17+
Works with simple names and unpacking, e.g.:
18+
- `for x in ...` -> {"x"}
19+
- `for x, y in ...` -> {"x", "y"}
20+
- `for (a, (b, c)) in ...` -> {"a", "b", "c"}
21+
"""
22+
return {node.id for node in ast.walk(target) if isinstance(node, ast.Name)}

wemake_python_styleguide/presets/types/tree.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
conditions.MatchVisitor,
7272
conditions.ChainedIsVisitor,
7373
conditions.SimplifiableMatchVisitor,
74+
conditions.LeakingForLoopVisitor,
7475
iterables.IterableUnpackingVisitor,
7576
blocks.AfterBlockVariablesVisitor,
7677
subscripts.SubscriptVisitor,

0 commit comments

Comments
 (0)