Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,11 @@ 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

26.10.27
~~~~~~~~
Expand Down
69 changes: 47 additions & 22 deletions bugbear.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,8 @@ def _binds_from_source(
"""Whether ``node`` binds ``name`` to ``<source>.<name>``.

That is ``from <source> import <name>``, ``from <source> import *`` or
``name = <source>.<name>`` with ``<source>`` imported as a module.
``name = <source>.<name>`` (optionally annotated) with ``<source>`` imported
as a module.
"""
if isinstance(node, ast.ImportFrom):
return (
Expand All @@ -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)
)


Expand All @@ -758,12 +764,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:
Comment on lines +767 to +769

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in be37743: _binds_from_source now treats an AnnAssign with a value like a plain assignment, and a bare zip: object still isn't a binding. The helper is shared, so this also applied to B018, B031 and B911 (the latter two since 26.10.27). Each has a *_shadowed_alias_annotated.py case now.

"""Whether the last module-level binding of ``name`` is ``<source>.<name>``.

Statements are taken in source order. A binding from ``<source>`` anywhere
in a statement counts for that statement, so a ``try``/``except`` fallback
for older Pythons still means ``<source>.<name>``.
for older Pythons still means ``<source>.<name>``. ``unbound`` is returned
when no module-level statement binds ``name``.
"""
source_modules = {
alias.asname or alias.name
Expand All @@ -772,7 +781,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)
Expand Down Expand Up @@ -845,8 +854,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="")
Expand Down Expand Up @@ -2208,17 +2218,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 ``<source>.<name>``."""
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 ``<source>.<name>``.

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.
Expand Down Expand Up @@ -2732,7 +2752,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):
Expand Down Expand Up @@ -3279,6 +3303,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)

Expand Down
11 changes: 11 additions & 0 deletions tests/eval_files/b018_shadowed_alias_annotated.py
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions tests/eval_files/b031_shadowed_alias_annotated.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions tests/eval_files/b905_shadowed.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions tests/eval_files/b905_shadowed_alias.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions tests/eval_files/b905_shadowed_alias_annotated.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions tests/eval_files/b905_shadowed_import.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions tests/eval_files/b905_shadowed_nested.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions tests/eval_files/b910_shadowed.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions tests/eval_files/b910_shadowed_alias.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions tests/eval_files/b910_shadowed_alias_annotated.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions tests/eval_files/b910_shadowed_import.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions tests/eval_files/b911_shadowed_alias_annotated.py
Original file line number Diff line number Diff line change
@@ -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
Loading