Skip to content

Commit a1b3159

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix/b005-message
# Conflicts: # README.rst
2 parents acbdc63 + c1cdd3d commit a1b3159

4 files changed

Lines changed: 101 additions & 0 deletions

File tree

‎README.rst‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,15 @@ There is no additional safety in using ``delattr`` if you know the attribute nam
316316

317317
**B044**: `assert <generator_expression>` is always true. Did you forget `all()`?
318318

319+
.. _B045:
320+
321+
**B045**: A loop control variable overrides a variable in an enclosing loop.
322+
Python loops do not create a new scope, so the inner loop overwrites the outer
323+
loop's value. Use different names for nested ``for`` and ``async for`` targets.
324+
Names beginning with an underscore are ignored to allow intentional discards.
325+
Separate function/class/comprehension scopes and a loop's ``else`` suite do not
326+
count as active enclosing loops.
327+
319328

320329
Opinionated warnings
321330
~~~~~~~~~~~~~~~~~~~~
@@ -518,6 +527,8 @@ Change Log
518527
UNRELEASED
519528
~~~~~~~~~~
520529

530+
* B045: detect reuse of an enclosing loop's control variable in a nested loop
531+
in the same scope (#360).
521532
* B005: say in the message that only strings with repeated characters are
522533
reported, since ``.strip("abc")`` style character sets are fine (#91)
523534
* B031: only treat a bare ``groupby()`` call as ``itertools.groupby`` when the

‎bugbear.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,7 @@ def visit_Assign(self, node: ast.Assign) -> None:
810810
self.generic_visit(node)
811811

812812
def visit_For(self, node: ast.For) -> None:
813+
self.check_for_b045(node)
813814
self.check_for_b007(node)
814815
self.check_for_b020(node)
815816
self.check_for_b023(node)
@@ -819,6 +820,7 @@ def visit_For(self, node: ast.For) -> None:
819820
self.generic_visit(node)
820821

821822
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
823+
self.check_for_b045(node)
822824
self.check_for_b023(node)
823825
self.generic_visit(node)
824826

@@ -2598,6 +2600,17 @@ def is_exception(s: str):
25982600
return
25992601
# no `def __init__` found, which is fine
26002602

2603+
def check_for_b045(self, node: ast.For | ast.AsyncFor) -> None:
2604+
# The stack belongs to the current scope: nested functions, classes,
2605+
# and comprehensions cannot overwrite an enclosing loop's binding.
2606+
outer_names = set()
2607+
for ancestor, child in zip(self.node_stack, self.node_stack[1:], strict=False):
2608+
if isinstance(ancestor, (ast.For, ast.AsyncFor)) and child in ancestor.body:
2609+
outer_names.update(names_from_assignments(ancestor.target))
2610+
for name in names_from_assignments(node.target):
2611+
if name in outer_names and not name.startswith("_"):
2612+
self.add_error("B045", node.target, name)
2613+
26012614
def check_for_b044(self, node: ast.Assert) -> None:
26022615
if isinstance(node.test, ast.GeneratorExp):
26032616
self.add_error("B044", node)
@@ -3400,6 +3413,12 @@ def __call__(self, lineno: int, col: int, vars: tuple[object, ...] = ()) -> erro
34003413
"B044 `assert <generator_expression>` is always true. Did you forget `all()`?"
34013414
)
34023415
),
3416+
"B045": Error(
3417+
message=(
3418+
"B045 Loop control variable {} overrides a variable in an enclosing loop. "
3419+
"Use a different name for the inner loop variable."
3420+
)
3421+
),
34033422
# Warnings disabled by default.
34043423
"B901": Error(
34053424
message=(

‎tests/eval_files/b045.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
for i in xs:
2+
for i in ys: # B045: 8, "i"
3+
print(i)
4+
print(i)
5+
6+
7+
async def check():
8+
async for value in xs:
9+
async for value in ys: # B045: 18, "value"
10+
print(value)
11+
print(value)

‎tests/test_bugbear.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,66 @@ def _parse_eval_file(test: str, content: str) -> tuple[list[error], Namespace |
102102
return expected, options
103103

104104

105+
@pytest.mark.parametrize(
106+
"source, expected",
107+
[
108+
("for i in xs:\n for i in ys:\n print(i)\n print(i)", ["i"]),
109+
(
110+
"for i in xs:\n for j in ys:\n for i in zs:\n print(i)",
111+
["i"],
112+
),
113+
("for i, j in xs:\n for j, i in ys:\n print(i, j)", ["j", "i"]),
114+
("for i in xs:\n for first, (*i,) in ys:\n print(i)", ["i"]),
115+
(
116+
"for i in xs:\n if flag:\n for i in ys:\n print(i)",
117+
["i"],
118+
),
119+
(
120+
"async def f():\n async for i in xs:\n"
121+
" for i in ys:\n print(i)",
122+
["i"],
123+
),
124+
(
125+
"async def f():\n for i in xs:\n"
126+
" async for i in ys:\n print(i)",
127+
["i"],
128+
),
129+
(
130+
"for i in xs:\n for i in ys:\n for i in zs:\n print(i)",
131+
["i", "i"],
132+
),
133+
("for i in xs:\n for j in ys:\n print(i, j)", []),
134+
("for i in xs:\n print(i)\nfor i in ys:\n print(i)", []),
135+
("for _ in xs:\n for _ in ys:\n pass", []),
136+
("for _unused in xs:\n for _unused in ys:\n pass", []),
137+
("for obj in xs:\n for obj.attr in ys:\n pass", []),
138+
("for i in xs:\n for values[i] in ys:\n pass", []),
139+
("for i in xs:\n for values[[i for i in zs][0]] in ys:\n pass", []),
140+
("for values[[i for i in zs][0]] in xs:\n for i in ys:\n pass", []),
141+
("for obj.attr in xs:\n for obj in ys:\n pass", []),
142+
("for i in xs:\n values = [i for i in ys]", []),
143+
("for i in xs:\n def f():\n for i in ys:\n print(i)", []),
144+
(
145+
"for i in xs:\n async def f():\n"
146+
" async for i in ys:\n print(i)",
147+
[],
148+
),
149+
("for i in xs:\n class C:\n for i in ys:\n print(i)", []),
150+
("for i in xs:\n print(i)\nelse:\n for i in ys:\n print(i)", []),
151+
(
152+
"for i in xs:\n for j in ys:\n pass\n"
153+
" else:\n for i in zs:\n print(i)",
154+
["i"],
155+
),
156+
],
157+
)
158+
def test_nested_loop_bindings(source, expected):
159+
visitor = BugBearVisitor(filename="example.py", lines=source.splitlines())
160+
visitor.visit(ast.parse(source))
161+
errors = [error for error in visitor.errors if error.message.startswith("B045")]
162+
assert [error.vars[0] for error in errors] == expected
163+
164+
105165
class BugbearTestCase(unittest.TestCase):
106166
maxDiff = None
107167

0 commit comments

Comments
 (0)