From 36370da5a608b11abefa369e2b55236207648a3b Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Mon, 26 Oct 2026 16:48:08 -0300 Subject: [PATCH 1/2] B911: don't flag a bare batched() the module binds to something else A bare batched() call was always treated as itertools.batched, so a module with its own `def batched(iterable, n)` (a common backport for Pythons before 3.12) or one imported from another library got B911 for a function that has no strict parameter. B911 now reuses the check B031 already does for groupby(): the bare name counts as itertools.batched only when the module imports it from itertools or doesn't bind it at all. A try/except fallback that imports it from itertools is still reported, and itertools.batched(...) calls are always checked. --- README.rst | 3 ++ bugbear.py | 44 ++++++++++++---------- tests/eval_files/b911_shadowed.py | 16 ++++++++ tests/eval_files/b911_shadowed_fallback.py | 17 +++++++++ tests/eval_files/b911_shadowed_import.py | 11 ++++++ 5 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 tests/eval_files/b911_shadowed.py create mode 100644 tests/eval_files/b911_shadowed_fallback.py create mode 100644 tests/eval_files/b911_shadowed_import.py diff --git a/README.rst b/README.rst index da27510..81dd4fa 100644 --- a/README.rst +++ b/README.rst @@ -615,6 +615,9 @@ UNRELEASED branches inside loops (#606) * B042: don't treat a class named like an exception as one when its only base is ``object``, as in ``class ParsedException(object)`` (#538) +* B911: only treat a bare ``batched()`` call as ``itertools.batched`` when the + module doesn't bind ``batched`` to something else, such as its own + ``def batched``, the same way B031 handles ``groupby()`` 26.9.30 ~~~~~~~ diff --git a/bugbear.py b/bugbear.py index 2afce72..72f1460 100644 --- a/bugbear.py +++ b/bugbear.py @@ -707,8 +707,8 @@ def _b906_definition_exprs( return exprs -def _b031_module_groupby_is_itertools(module: ast.Module) -> bool: - """Whether a bare ``groupby`` is ``itertools.groupby`` or never bound. +def _module_name_is_itertools(module: ast.Module, name: str) -> bool: + """Whether a bare ``name`` is ``itertools.`` or never bound. Whole-module ``ast.walk``; nested scopes are intentionally not modeled. """ @@ -716,12 +716,12 @@ def _b031_module_groupby_is_itertools(module: ast.Module) -> bool: other = False for node in ast.walk(module): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - if node.name == "groupby": + if node.name == name: other = True elif isinstance(node, (ast.Import, ast.ImportFrom)): for alias in node.names: - # Absolute ``from itertools import *`` binds ``groupby``. - # Other star imports do not. ``import groupby.sub`` does. + # Absolute ``from itertools import *`` binds ``name``. + # Other star imports do not. ``import name.sub`` does. if alias.name == "*": if ( isinstance(node, ast.ImportFrom) @@ -731,27 +731,27 @@ def _b031_module_groupby_is_itertools(module: ast.Module) -> bool: from_itertools = True continue bound = alias.asname or alias.name.split(".", 1)[0] - if bound != "groupby": + if bound != name: continue if ( isinstance(node, ast.ImportFrom) and node.level == 0 and node.module == "itertools" - and alias.name == "groupby" + and alias.name == name ): from_itertools = True else: other = True elif ( isinstance(node, ast.Name) - and node.id == "groupby" + and node.id == name and isinstance(node.ctx, ast.Store) ): other = True elif ( isinstance(node, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)) - and node.name == "groupby" - ) or (isinstance(node, ast.MatchMapping) and node.rest == "groupby"): + and node.name == name + ) or (isinstance(node, ast.MatchMapping) and node.rest == name): other = True return from_itertools or not other @@ -783,8 +783,8 @@ class BugBearVisitor(ast.NodeVisitor): _b008_imports: dict[str, str | None] = attr.ib(factory=dict, init=False) _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) - # None until the enclosing module has been scanned for bindings of groupby. - _b031_bare_groupby_is_itertools: bool | None = attr.ib(default=None, init=False) + # Bare itertools names (groupby, batched) already resolved for this module. + _bare_itertools_names: dict[str, bool] = attr.ib(factory=dict, init=False) # set to "*" when inside a try/except*, for correctly printing errors in_trystar: str = attr.ib(default="") @@ -2146,17 +2146,17 @@ def _check_b031_group_usage( ) return num_usages - def _b031_groupby_is_itertools(self) -> bool: - """Whether a bare ``groupby()`` in this module is ``itertools.groupby``.""" - if self._b031_bare_groupby_is_itertools is None: + def _bare_name_is_itertools(self, name: str) -> bool: + """Whether a bare ``name()`` in this module is ``itertools.``.""" + if name not in self._bare_itertools_names: module = self.contexts[0].node if self.contexts else None # No module to inspect: keep the historical report. - self._b031_bare_groupby_is_itertools = ( - _b031_module_groupby_is_itertools(module) + self._bare_itertools_names[name] = ( + _module_name_is_itertools(module, name) if isinstance(module, ast.Module) else True ) - return self._b031_bare_groupby_is_itertools + return self._bare_itertools_names[name] def check_for_b031(self, loop_node: ast.For) -> None: # noqa: C901 """Check that `itertools.groupby` isn't iterated over more than once. @@ -2171,7 +2171,7 @@ def check_for_b031(self, loop_node: ast.For) -> None: # noqa: C901 if ( isinstance(node.func, ast.Name) and node.func.id == "groupby" - and self._b031_groupby_is_itertools() + and self._bare_name_is_itertools("groupby") ) or ( isinstance(node.func, ast.Attribute) and node.func.attr == "groupby" @@ -3219,7 +3219,11 @@ def check_for_b910(self, node: ast.Call) -> None: def check_for_b911(self, node: ast.Call) -> None: if ( - (isinstance(node.func, ast.Name) and node.func.id == "batched") + ( + isinstance(node.func, ast.Name) + and node.func.id == "batched" + and self._bare_name_is_itertools("batched") + ) or ( isinstance(node.func, ast.Attribute) and node.func.attr == "batched" diff --git a/tests/eval_files/b911_shadowed.py b/tests/eval_files/b911_shadowed.py new file mode 100644 index 0000000..e093f3c --- /dev/null +++ b/tests/eval_files/b911_shadowed.py @@ -0,0 +1,16 @@ +""" +Should emit: +B911 - on line 16 +""" + +# A module-level function named batched is not itertools.batched. +import itertools + + +def batched(iterable, n): + it = iter(iterable) + return iter(lambda: tuple(itertools.islice(it, n)), ()) + + +batched(range(3), 2) +itertools.batched(range(3), 2) # B911: 0 diff --git a/tests/eval_files/b911_shadowed_fallback.py b/tests/eval_files/b911_shadowed_fallback.py new file mode 100644 index 0000000..5d514ff --- /dev/null +++ b/tests/eval_files/b911_shadowed_fallback.py @@ -0,0 +1,17 @@ +""" +Should emit: +B911 - on line 17 +""" + +# A fallback for older Pythons still means batched can be itertools.batched. +try: + from itertools import batched +except ImportError: + import itertools + + def batched(iterable, n): + it = iter(iterable) + return iter(lambda: tuple(itertools.islice(it, n)), ()) + + +batched(range(3), 2) # B911: 0 diff --git a/tests/eval_files/b911_shadowed_import.py b/tests/eval_files/b911_shadowed_import.py new file mode 100644 index 0000000..f7718eb --- /dev/null +++ b/tests/eval_files/b911_shadowed_import.py @@ -0,0 +1,11 @@ +""" +Should emit: +B911 - on line 11 +""" + +import itertools + +from mylib import batched + +batched(range(3), 2) +itertools.batched(range(3), 2) # B911: 0 From f7e24754c78561416f386c6496dbf4e57b0b41e2 Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Tue, 27 Oct 2026 08:22:00 -0300 Subject: [PATCH 2/2] Treat assignments from itertools.batched as itertools --- bugbear.py | 24 ++++++++++++++++++- tests/eval_files/b031_shadowed_alias.py | 15 ++++++++++++ tests/eval_files/b911_shadowed_alias.py | 11 +++++++++ .../b911_shadowed_alias_fallback.py | 18 ++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/eval_files/b031_shadowed_alias.py create mode 100644 tests/eval_files/b911_shadowed_alias.py create mode 100644 tests/eval_files/b911_shadowed_alias_fallback.py diff --git a/bugbear.py b/bugbear.py index 72f1460..265b0f2 100644 --- a/bugbear.py +++ b/bugbear.py @@ -707,6 +707,28 @@ def _b906_definition_exprs( return exprs +def _module_assigns_itertools_attr(module: ast.Module, name: str) -> bool: + """Whether the module has ``name = itertools.``, e.g. in a fallback.""" + itertools_modules: set[str] = set() + bases: set[str] = set() + for node in ast.walk(module): + if isinstance(node, ast.Import): + itertools_modules.update( + alias.asname or alias.name + for alias in node.names + if alias.name == "itertools" + ) + elif ( + isinstance(node, ast.Assign) + and isinstance(node.value, ast.Attribute) + and node.value.attr == name + and isinstance(node.value.value, ast.Name) + and any(isinstance(t, ast.Name) and t.id == name for t in node.targets) + ): + bases.add(node.value.value.id) + return not itertools_modules.isdisjoint(bases) + + def _module_name_is_itertools(module: ast.Module, name: str) -> bool: """Whether a bare ``name`` is ``itertools.`` or never bound. @@ -753,7 +775,7 @@ def _module_name_is_itertools(module: ast.Module, name: str) -> bool: and node.name == name ) or (isinstance(node, ast.MatchMapping) and node.rest == name): other = True - return from_itertools or not other + return from_itertools or not other or _module_assigns_itertools_attr(module, name) @attr.s diff --git a/tests/eval_files/b031_shadowed_alias.py b/tests/eval_files/b031_shadowed_alias.py new file mode 100644 index 0000000..86d24cc --- /dev/null +++ b/tests/eval_files/b031_shadowed_alias.py @@ -0,0 +1,15 @@ +""" +Should emit: +B031 - on line 14 +""" + +# Assigning itertools.groupby to groupby still means itertools.groupby. +import itertools + +groupby = itertools.groupby + +for _, v in groupby(None, None): + if v: + pass + if v: # B031: 7, "v" + pass diff --git a/tests/eval_files/b911_shadowed_alias.py b/tests/eval_files/b911_shadowed_alias.py new file mode 100644 index 0000000..6e9cc18 --- /dev/null +++ b/tests/eval_files/b911_shadowed_alias.py @@ -0,0 +1,11 @@ +""" +Should emit: +B911 - on line 11 +""" + +# Assigning itertools.batched to batched still means itertools.batched. +import itertools + +batched = itertools.batched + +batched(range(3), 2) # B911: 0 diff --git a/tests/eval_files/b911_shadowed_alias_fallback.py b/tests/eval_files/b911_shadowed_alias_fallback.py new file mode 100644 index 0000000..d926bf8 --- /dev/null +++ b/tests/eval_files/b911_shadowed_alias_fallback.py @@ -0,0 +1,18 @@ +""" +Should emit: +B911 - on line 18 +""" + +# A fallback for older Pythons still means batched can be itertools.batched. +import itertools + +try: + batched = itertools.batched +except AttributeError: + + def batched(iterable, n): + it = iter(iterable) + return iter(lambda: tuple(itertools.islice(it, n)), ()) + + +batched(range(3), 2) # B911: 0