From 5d976af8a589fad721c9d1e2289cf2a30b1d36ef Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Wed, 28 Oct 2026 20:33:42 -0300 Subject: [PATCH 1/2] B905, B910: don't flag zip() and defaultdict() the module binds to something else B905 reported every bare zip() call without strict=, and B910 every bare defaultdict(int), even when the module defines its own zip() or defaultdict, or imports one from another library. Those may not take strict= at all, or may not be collections.defaultdict. Both now look at the module-level bindings of the name, like B018, B031 and B911 do: the last one decides, and a name the module never binds is the builtin/collections one. Unlike those checks, a method, class attribute or local variable called zip doesn't count, since zip is common enough that the whole-module rule would hide many real reports (numpy, mypy and sortedcontainers all have one). --- README.rst | 3 ++ bugbear.py | 49 ++++++++++++++++-------- tests/eval_files/b905_shadowed.py | 18 +++++++++ tests/eval_files/b905_shadowed_alias.py | 11 ++++++ tests/eval_files/b905_shadowed_import.py | 15 ++++++++ tests/eval_files/b905_shadowed_nested.py | 26 +++++++++++++ tests/eval_files/b910_shadowed.py | 18 +++++++++ tests/eval_files/b910_shadowed_alias.py | 11 ++++++ tests/eval_files/b910_shadowed_import.py | 13 +++++++ 9 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 tests/eval_files/b905_shadowed.py create mode 100644 tests/eval_files/b905_shadowed_alias.py create mode 100644 tests/eval_files/b905_shadowed_import.py create mode 100644 tests/eval_files/b905_shadowed_nested.py create mode 100644 tests/eval_files/b910_shadowed.py create mode 100644 tests/eval_files/b910_shadowed_alias.py create mode 100644 tests/eval_files/b910_shadowed_import.py diff --git a/README.rst b/README.rst index 81fbd28..d17c9db 100644 --- a/README.rst +++ b/README.rst @@ -573,6 +573,9 @@ UNRELEASED defines or imports its own function under that name, which may well do the work * B031, B911: a bare ``groupby``/``batched`` imported from ``itertools`` no longer counts as ``itertools`` when a later module-level ``def`` or assignment replaces it +* B905, B910: only report ``zip()`` and ``defaultdict(int)`` when the name is the + builtin ``zip``/``collections.defaultdict``, not when a module-level ``def``, + ``class``, import or assignment replaces it with something else 26.10.27 ~~~~~~~~ diff --git a/bugbear.py b/bugbear.py index 1e27e6c..797c7ff 100644 --- a/bugbear.py +++ b/bugbear.py @@ -758,12 +758,15 @@ def _rebinds_elsewhere(stmt: ast.stmt, name: str) -> bool: ) -def _module_binding_is_from(module: ast.Module, source: str, name: str) -> bool: +def _module_binding_is_from( + module: ast.Module, source: str, name: str, unbound: bool = False +) -> bool: """Whether the last module-level binding of ``name`` is ``.``. Statements are taken in source order. A binding from ```` anywhere in a statement counts for that statement, so a ``try``/``except`` fallback - for older Pythons still means ``.``. + for older Pythons still means ``.``. ``unbound`` is returned + when no module-level statement binds ``name``. """ source_modules = { alias.asname or alias.name @@ -772,7 +775,7 @@ def _module_binding_is_from(module: ast.Module, source: str, name: str) -> bool: for alias in node.names if alias.name == source } - is_from = False + is_from = unbound for stmt in module.body: if any( _binds_from_source(node, source, name, source_modules) @@ -845,8 +848,9 @@ class BugBearVisitor(ast.NodeVisitor): _b008_class_imports: list[dict[str, str | None]] = attr.ib(factory=list, init=False) _b008_class_globals: list[set[str]] = attr.ib(factory=list, init=False) # Bare names (groupby, batched, map) already resolved for this module, keyed - # by the module they are expected to come from. - _bare_names: dict[tuple[str, str], bool] = attr.ib(factory=dict, init=False) + # by the module they are expected to come from and whether only module-level + # bindings count. + _bare_names: dict[tuple[str, str, bool], bool] = attr.ib(factory=dict, init=False) # set to "*" when inside a try/except*, for correctly printing errors in_trystar: str = attr.ib(default="") @@ -2208,17 +2212,27 @@ def _check_b031_group_usage( ) return num_usages - def _bare_name_is_from(self, source: str, name: str) -> bool: - """Whether a bare ``name()`` in this module is ``.``.""" - if (source, name) not in self._bare_names: + def _bare_name_is_from( + self, source: str, name: str, module_level: bool = False + ) -> bool: + """Whether a bare ``name()`` in this module is ``.``. + + With ``module_level``, only module-level statements can rebind ``name``, + so a method, class attribute or local variable of that name is ignored. + """ + key = (source, name, module_level) + if key not in self._bare_names: module = self.contexts[0].node if self.contexts else None # No module to inspect: keep the historical report. - self._bare_names[source, name] = ( - _module_name_is_from(module, source, name) - if isinstance(module, ast.Module) - else True - ) - return self._bare_names[source, name] + if not isinstance(module, ast.Module): + self._bare_names[key] = True + elif module_level: + self._bare_names[key] = _module_binding_is_from( + module, source, name, unbound=True + ) + else: + self._bare_names[key] = _module_name_is_from(module, source, name) + return self._bare_names[key] def check_for_b031(self, loop_node: ast.For) -> None: # noqa: C901 """Check that `itertools.groupby` isn't iterated over more than once. @@ -2732,7 +2746,11 @@ def _is_infinite_iterator(node: ast.expr) -> bool: return False def check_for_b905(self, node: ast.Call) -> None: - if not (isinstance(node.func, ast.Name) and node.func.id == "zip"): + if not ( + isinstance(node.func, ast.Name) + and node.func.id == "zip" + and self._bare_name_is_from("builtins", "zip", module_level=True) + ): return for arg in node.args: if self._is_infinite_iterator(arg): @@ -3279,6 +3297,7 @@ def check_for_b910(self, node: ast.Call) -> None: and len(node.args) > 0 and isinstance(node.args[0], ast.Name) and node.args[0].id == "int" + and self._bare_name_is_from("collections", "defaultdict", module_level=True) ): self.add_error("B910", node) diff --git a/tests/eval_files/b905_shadowed.py b/tests/eval_files/b905_shadowed.py new file mode 100644 index 0000000..04f11a0 --- /dev/null +++ b/tests/eval_files/b905_shadowed.py @@ -0,0 +1,18 @@ +""" +Should emit: +B910 - on line 18 +""" + +# A module-level function named zip is not the builtin zip(). +from collections import defaultdict +from itertools import zip_longest + + +def zip(*iterables): + return zip_longest(*iterables, fillvalue="") + + +_ = zip("a", "bc") + +# defaultdict() is still collections.defaultdict +_ = defaultdict(int) # B910: 4 diff --git a/tests/eval_files/b905_shadowed_alias.py b/tests/eval_files/b905_shadowed_alias.py new file mode 100644 index 0000000..3949ce7 --- /dev/null +++ b/tests/eval_files/b905_shadowed_alias.py @@ -0,0 +1,11 @@ +""" +Should emit: +B905 - on line 11 +""" + +# Assigning builtins.zip to zip still means the builtin. +import builtins + +zip = builtins.zip + +_ = zip("a", "bc") # B905: 4 diff --git a/tests/eval_files/b905_shadowed_import.py b/tests/eval_files/b905_shadowed_import.py new file mode 100644 index 0000000..2d61f86 --- /dev/null +++ b/tests/eval_files/b905_shadowed_import.py @@ -0,0 +1,15 @@ +""" +Should emit: +B910 - on line 15 +""" + +# zip() from another library is not the builtin, even after importing that. +from builtins import zip +from collections import defaultdict + +from mylib import zip + +_ = zip("a", "bc") + +# defaultdict() is still collections.defaultdict +_ = defaultdict(int) # B910: 4 diff --git a/tests/eval_files/b905_shadowed_nested.py b/tests/eval_files/b905_shadowed_nested.py new file mode 100644 index 0000000..7b2facf --- /dev/null +++ b/tests/eval_files/b905_shadowed_nested.py @@ -0,0 +1,26 @@ +""" +Should emit: +B905 - on line 25 +B910 - on line 26 +""" + +# Only module-level bindings replace zip and defaultdict. A method or class +# attribute of that name, or an import that only runs on Python 2, leaves the +# module's zip() the builtin. +import sys +from collections import defaultdict + +if sys.version_info < (3,): + from itertools import izip as zip + + +class Archive: + zip = None + defaultdict = None + + def zip(self, other): + return self + + +_ = zip("a", "bc") # B905: 4 +_ = defaultdict(int) # B910: 4 diff --git a/tests/eval_files/b910_shadowed.py b/tests/eval_files/b910_shadowed.py new file mode 100644 index 0000000..37e7755 --- /dev/null +++ b/tests/eval_files/b910_shadowed.py @@ -0,0 +1,18 @@ +""" +Should emit: +B905 - on line 18 +""" + +# A module-level class named defaultdict is not collections.defaultdict. +import collections + + +class defaultdict(collections.defaultdict): + def __missing__(self, key): + return self.default_factory(key) + + +_ = defaultdict(int) + +# zip() is still the builtin +_ = zip("a", "bc") # B905: 4 diff --git a/tests/eval_files/b910_shadowed_alias.py b/tests/eval_files/b910_shadowed_alias.py new file mode 100644 index 0000000..0e9a6eb --- /dev/null +++ b/tests/eval_files/b910_shadowed_alias.py @@ -0,0 +1,11 @@ +""" +Should emit: +B910 - on line 11 +""" + +# Assigning collections.defaultdict to defaultdict still means collections. +import collections + +defaultdict = collections.defaultdict + +_ = defaultdict(int) # B910: 4 diff --git a/tests/eval_files/b910_shadowed_import.py b/tests/eval_files/b910_shadowed_import.py new file mode 100644 index 0000000..5ed22ef --- /dev/null +++ b/tests/eval_files/b910_shadowed_import.py @@ -0,0 +1,13 @@ +""" +Should emit: +B905 - on line 13 +""" + +# defaultdict() from another library is not collections.defaultdict. +from mylib import defaultdict + +_ = defaultdict(int) +_ = defaultdict(int, {"a": 1}) + +# zip() is still the builtin +_ = zip("a", "bc") # B905: 4 From be37743fc7adc0918c061e7572a4c8928fa4a05a Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Thu, 29 Oct 2026 20:42:54 -0300 Subject: [PATCH 2/2] Treat annotated assignments from the source module like plain ones --- README.rst | 2 ++ bugbear.py | 20 ++++++++++++------- .../b018_shadowed_alias_annotated.py | 11 ++++++++++ .../b031_shadowed_alias_annotated.py | 16 +++++++++++++++ .../b905_shadowed_alias_annotated.py | 13 ++++++++++++ .../b910_shadowed_alias_annotated.py | 13 ++++++++++++ .../b911_shadowed_alias_annotated.py | 12 +++++++++++ 7 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 tests/eval_files/b018_shadowed_alias_annotated.py create mode 100644 tests/eval_files/b031_shadowed_alias_annotated.py create mode 100644 tests/eval_files/b905_shadowed_alias_annotated.py create mode 100644 tests/eval_files/b910_shadowed_alias_annotated.py create mode 100644 tests/eval_files/b911_shadowed_alias_annotated.py diff --git a/README.rst b/README.rst index d17c9db..642b510 100644 --- a/README.rst +++ b/README.rst @@ -573,6 +573,8 @@ UNRELEASED defines or imports its own function under that name, which may well do the work * B031, B911: a bare ``groupby``/``batched`` imported from ``itertools`` no longer counts as ``itertools`` when a later module-level ``def`` or assignment replaces it +* B031, B911: an annotated ``batched: Callable = itertools.batched`` counts as + ``itertools``, like the same assignment without the annotation * B905, B910: only report ``zip()`` and ``defaultdict(int)`` when the name is the builtin ``zip``/``collections.defaultdict``, not when a module-level ``def``, ``class``, import or assignment replaces it with something else diff --git a/bugbear.py b/bugbear.py index 797c7ff..2048d5f 100644 --- a/bugbear.py +++ b/bugbear.py @@ -713,7 +713,8 @@ def _binds_from_source( """Whether ``node`` binds ``name`` to ``.``. That is ``from import ``, ``from import *`` or - ``name = .`` with ```` imported as a module. + ``name = .`` (optionally annotated) with ```` imported + as a module. """ if isinstance(node, ast.ImportFrom): return ( @@ -725,13 +726,18 @@ def _binds_from_source( for alias in node.names ) ) + if isinstance(node, ast.Assign): + targets, value = node.targets, node.value + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets, value = [node.target], node.value + else: + return False return ( - isinstance(node, ast.Assign) - and isinstance(node.value, ast.Attribute) - and node.value.attr == name - and isinstance(node.value.value, ast.Name) - and node.value.value.id in source_modules - and any(isinstance(t, ast.Name) and t.id == name for t in node.targets) + isinstance(value, ast.Attribute) + and value.attr == name + and isinstance(value.value, ast.Name) + and value.value.id in source_modules + and any(isinstance(t, ast.Name) and t.id == name for t in targets) ) diff --git a/tests/eval_files/b018_shadowed_alias_annotated.py b/tests/eval_files/b018_shadowed_alias_annotated.py new file mode 100644 index 0000000..9d8113f --- /dev/null +++ b/tests/eval_files/b018_shadowed_alias_annotated.py @@ -0,0 +1,11 @@ +""" +Should emit: +B018 - on line 11 +""" + +# An annotated assignment of builtins.map to map still means the lazy builtin. +import builtins + +map: object = builtins.map + +map(print, [1, 2]) # B018: 0, "Call" diff --git a/tests/eval_files/b031_shadowed_alias_annotated.py b/tests/eval_files/b031_shadowed_alias_annotated.py new file mode 100644 index 0000000..262fa0b --- /dev/null +++ b/tests/eval_files/b031_shadowed_alias_annotated.py @@ -0,0 +1,16 @@ +""" +Should emit: +B031 - on line 15 +""" + +# An annotated assignment of itertools.groupby to groupby still means +# itertools.groupby. +import itertools + +groupby: object = itertools.groupby + +for _, v in groupby(None, None): + if v: + pass + if v: # B031: 7, "v" + pass diff --git a/tests/eval_files/b905_shadowed_alias_annotated.py b/tests/eval_files/b905_shadowed_alias_annotated.py new file mode 100644 index 0000000..89ee3c0 --- /dev/null +++ b/tests/eval_files/b905_shadowed_alias_annotated.py @@ -0,0 +1,13 @@ +""" +Should emit: +B905 - on line 13 +""" + +# An annotated assignment of builtins.zip to zip still means the builtin, and a +# bare annotation doesn't bind zip at all. +import builtins + +zip: object = builtins.zip +zip: object + +_ = zip("a", "bc") # B905: 4 diff --git a/tests/eval_files/b910_shadowed_alias_annotated.py b/tests/eval_files/b910_shadowed_alias_annotated.py new file mode 100644 index 0000000..6cbc691 --- /dev/null +++ b/tests/eval_files/b910_shadowed_alias_annotated.py @@ -0,0 +1,13 @@ +""" +Should emit: +B910 - on line 13 +""" + +# An annotated assignment of collections.defaultdict to defaultdict still means +# collections.defaultdict, and a bare annotation doesn't bind it at all. +import collections + +defaultdict: type = collections.defaultdict +defaultdict: type + +_ = defaultdict(int) # B910: 4 diff --git a/tests/eval_files/b911_shadowed_alias_annotated.py b/tests/eval_files/b911_shadowed_alias_annotated.py new file mode 100644 index 0000000..fb38baa --- /dev/null +++ b/tests/eval_files/b911_shadowed_alias_annotated.py @@ -0,0 +1,12 @@ +""" +Should emit: +B911 - on line 12 +""" + +# An annotated assignment of itertools.batched to batched still means +# itertools.batched. +import itertools + +batched: object = itertools.batched + +batched(range(3), 2) # B911: 0