B020: don't flag rebinding an attribute of the loop's base object - #568
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes B020 false positives when loop targets and iterables use different attributes of the same object.
Changes:
- Compares complete dotted attribute paths.
- Ignores load-only target names.
- Adds regression cases and changelog documentation.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
bugbear.py |
Updates B020 attribute-target analysis. |
tests/eval_files/b020.py |
Adds attribute-target regression cases. |
README.rst |
Documents the B020 fix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| for sub in ast.walk(node.iter): | ||
| if isinstance(sub, ast.Attribute): | ||
| path = _dotted_name(sub) | ||
| if path is not None: | ||
| iterset_names.add(path) |
| print(self.value) | ||
|
|
||
| def still_an_error(self): | ||
| for self.test_suite in self.test_suite: # B020: 12, "self.test_suite" |
cooperlees
left a comment
There was a problem hiding this comment.
LGTM - Thanks for this.
I think copilot has found some nice performance things especially to polish up here - As always, feel free to state why it's wrong tho if it is.
|
Thanks. Both are right. The scope point is the substantive one: I collected dotted paths with The stale |
`for self.a in self.b` rebinds the attribute `a`, not the name `self`, so comparing the bare base name reported every loop over a sibling attribute of the same object. Compare the whole dotted path instead, and ignore names that only ever appear in load context (they are the base of an attribute target, not something the loop rebinds). Fixes PyCQA#248
Addresses the two review points.
Walking node.iter with ast.walk ignored lexical scope, which the name
side of the check does not: B020NameFinder skips names bound by a
comprehension or a lambda. So in
for obj.value in [obj.value for obj in objects]:
the comprehension-local obj.value was matched against the loop target
and the loop was reported. B020AttributeFinder now collects the paths,
inheriting those exclusions, and drops paths rooted in a lambda
argument the same way the base class drops the argument names.
The eval file's 'Should emit' header is refreshed. It was stale by more
than the new line: line 32 was missing from it as well.
69c1eb1 to
c54fb35
Compare
|
Both done, and the branch is rebased onto main so the conflict is gone.
Before / after, same three files
Both scopes are now in The header was stale by more than one lineListing line 58 turned out not to be enough: line 32 was missing from it too, from before this branch. The header now reads and those are exactly the six the checker produces on the file — I compared the parsed expectations against
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (3)
bugbear.py:2273
- The inherited comprehension handling does not override
visit_SetComp, so a set-comprehension-local root is collected as if it were outer scope. For example,for obj.value in {obj.value for obj in objects}:still emits a false B020, unlike the equivalent list-comprehension case added in this PR. AddSetCompto the same scope handling asListCompandDictComp.
class B020AttributeFinder(B020NameFinder):
"""Dotted attribute paths, under the scope rules B020NameFinder uses for names.
bugbear.py:2293
- This removes matching paths from the shared result set, including paths found before entering the lambda. Thus
for obj.value in (obj.value, lambda obj: obj.value):incorrectly loses the outerobj.valuematch and emits no B020. Isolate paths collected from the lambda body before filtering its bound parameters; also include positional-only, keyword-only, variadic, and keyword-variadic parameters so those lambda-local roots do not cause false positives.
def visit_Lambda(self, node: ast.Lambda) -> None:
super().visit_Lambda(node)
for lambda_arg in node.args.args:
prefix = f"{lambda_arg.arg}."
self.paths = {path for path in self.paths if not path.startswith(prefix)}
bugbear.py:995
- Use the actual store-context node for the diagnostic. In a destructuring target such as
for self.value, self in self:, the firstselfis a load used as an attribute base, sonames[0]reports B020 at the wrong control target even though the laterselfis the binding that triggers it.
This issue also appears in the following locations of the same file:
- line 2272
- line 2289
candidates[name] = names[0]
Fixes #248.
The false positive
for self.a in self.brebinds the attributea— it does not rebind the nameself.check_for_b020compared the bare base name of the loop target against the names in theiterable, so
selfmatchedself, and every loop over a sibling attribute of the sameobject was reported:
self.model_instance.valueandself.test_suiteare two different bindings, so this loopcannot reassign the thing it is iterating.
The fix
Two changes in
check_for_b020:self.model_instance.valueagainstself.test_suite)rather than the base name, on both the target and the iterable side;
attribute or subscript target, not something the loop rebinds.
What still errors
The case the check exists for is unchanged, and there is an eval case pinning it:
Checks
tests/eval_files/b020.pygains three cases — two that must now be silent and one thatmust still error. Reverting only
bugbear.pymakes the file fail with exactly the twoextra
B020s, so the new cases do pin this fix rather than passing incidentally.2155484: 79 passed, 2 skipped — same asmain.pre-commit run --all-files: isort, black, flake8, rstcheck all pass.README.rstUNRELEASEDupdated.@cooperlees — you wrote on the issue that you weren't sure this one could be fixed.
Comparing full dotted paths turned out to be enough; happy to adjust the approach if you'd
rather this stayed a known limitation.