Skip to content

B020: don't flag rebinding an attribute of the loop's base object - #568

Merged
cooperlees merged 2 commits into
PyCQA:mainfrom
Eljees:fix/248-b020-attribute-target
Aug 22, 2026
Merged

B020: don't flag rebinding an attribute of the loop's base object#568
cooperlees merged 2 commits into
PyCQA:mainfrom
Eljees:fix/248-b020-attribute-target

Conversation

@Eljees

@Eljees Eljees commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #248.

The false positive

for self.a in self.b rebinds the attribute a — it does not rebind the name self.
check_for_b020 compared the bare base name of the loop target against the names in the
iterable, so self matched self, and every loop over a sibling attribute of the same
object was reported:

class A:
    test_suite = [1, 2, 3]

    def method(self):
        for self.model_instance.value in self.test_suite:  # B020 (false positive)
            print(self.model_instance.value)

self.model_instance.value and self.test_suite are two different bindings, so this loop
cannot reassign the thing it is iterating.

The fix

Two changes in check_for_b020:

  • compare the whole dotted path (self.model_instance.value against self.test_suite)
    rather than the base name, on both the target and the iterable side;
  • ignore names that only ever appear in load context — those are the base of an
    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:

for self.test_suite in self.test_suite:  # B020: 12, "self.test_suite"

Checks

  • tests/eval_files/b020.py gains three cases — two that must now be silent and one that
    must still error. Reverting only bugbear.py makes the file fail with exactly the two
    extra B020s, so the new cases do pin this fix rather than passing incidentally.
  • Full suite on 2155484: 79 passed, 2 skipped — same as main.
  • pre-commit run --all-files: isort, black, flake8, rstcheck all pass.
  • README.rst UNRELEASED updated.

@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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread bugbear.py Outdated
Comment on lines +986 to +990
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)
Comment thread tests/eval_files/b020.py
print(self.value)

def still_an_error(self):
for self.test_suite in self.test_suite: # B020: 12, "self.test_suite"

@cooperlees cooperlees left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Eljees

Eljees commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Both are right.

The scope point is the substantive one: I collected dotted paths with ast.walk, so a comprehension- or lambda-local binding is treated as the same object as the outer one, and for obj.value in [obj.value for obj in objects] regresses. I will collect the paths with a visitor that reuses the comprehension and lambda exclusions B020NameFinder already applies, and add both scopes as regression cases.

The stale Should emit header is my oversight — line 58 will be listed.

Eljees added 2 commits August 21, 2026 08:21
`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.
@Eljees
Eljees force-pushed the fix/248-b020-attribute-target branch from 69c1eb1 to c54fb35 Compare August 21, 2026 08:24
@Eljees

Eljees commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Both done, and the branch is rebased onto main so the conflict is gone.

B020AttributeFinder now collects the dotted paths as a subclass of B020NameFinder, so it inherits the comprehension and lambda exclusions instead of restating them — that was the part I wanted to avoid duplicating, since the two would drift. The one thing the base class does that had to be mirrored is visit_Lambda: it pops the lambda arguments out of names after visiting the body, so the subclass drops the paths rooted in those arguments the same way.

Before / after, same three files

previous revision this revision
for obj.value in [obj.value for obj in objects] reported silent
for obj.value in map(lambda obj: obj.value, objects) reported silent
for obj.value in obj.value reported reported

Both scopes are now in tests/eval_files/b020.py as ok_comprehension_scope and ok_lambda_scope, with still_an_error_at_module_level next to them as the control.

The header was stale by more than one line

Listing line 58 turned out not to be enough: line 32 was missing from it too, from before this branch. The header now reads

B020 - on lines 8, 21, 32, 36, 58, and 75

and those are exactly the six the checker produces on the file — I compared the parsed expectations against BugBearChecker.run() directly, nothing missing and nothing extra.

python -m pytest: 79 passed, 2 skipped, same as the baseline on 838c365. black and isort clean on both changed files.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Add SetComp to the same scope handling as ListComp and DictComp.
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 outer obj.value match 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 first self is a load used as an attribute base, so names[0] reports B020 at the wrong control target even though the later self is 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]

@cooperlees
cooperlees merged commit 4b502c5 into PyCQA:main Aug 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Possible B020 false positive with instance attribute

3 participants