Skip to content

Commit 42ea470

Browse files
authored
Merge pull request #588 from LuisFigueroaG/feat/dict-fromkeys-mutable
Add B046: mutable value passed to dict.fromkeys()
2 parents d8c5796 + e1ea101 commit 42ea470

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

‎README.rst‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ Names beginning with an underscore are ignored to allow intentional discards.
325325
Separate function/class/comprehension scopes and a loop's ``else`` suite do not
326326
count as active enclosing loops.
327327

328+
.. _B046:
329+
330+
**B046**: Mutable value passed to ``dict.fromkeys()``. The same object is shared by every key,
331+
so changing it for one key changes it for all of them. Use a dict comprehension instead, e.g.
332+
``{key: [] for key in keys}``.
333+
328334

329335
Opinionated warnings
330336
~~~~~~~~~~~~~~~~~~~~
@@ -529,6 +535,8 @@ UNRELEASED
529535

530536
* B045: detect reuse of an enclosing loop's control variable in a nested loop
531537
in the same scope (#360).
538+
* B046: New check for ``dict.fromkeys()`` called with a mutable value, which is
539+
shared by every key (#387)
532540
* B005: say in the message that only strings with repeated characters are
533541
reported, since ``.strip("abc")`` style character sets are fine (#91)
534542
* B031: only treat a bare ``groupby()`` call as ``itertools.groupby`` when the

‎bugbear.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@ def visit_Call(self, node: ast.Call) -> None:
762762
self.check_for_b028(node)
763763
self.check_for_b034(node)
764764
self.check_for_b039(node)
765+
self.check_for_b046(node)
765766
self.check_for_b905(node)
766767
self.check_for_b910(node)
767768
self.check_for_b911(node)
@@ -2615,6 +2616,31 @@ def check_for_b044(self, node: ast.Assert) -> None:
26152616
if isinstance(node.test, ast.GeneratorExp):
26162617
self.add_error("B044", node)
26172618

2619+
def check_for_b046(self, node: ast.Call) -> None:
2620+
if not (
2621+
isinstance(node.func, ast.Attribute)
2622+
and node.func.attr == "fromkeys"
2623+
and ".".join(compose_call_path(node.func.value)) in B046_DICT_TYPES
2624+
):
2625+
return
2626+
2627+
if len(node.args) == 2:
2628+
value = node.args[1]
2629+
elif len(node.args) == 1:
2630+
# OrderedDict.fromkeys() also takes the value as a keyword argument
2631+
value = next((kw.value for kw in node.keywords if kw.arg == "value"), None)
2632+
else:
2633+
return
2634+
2635+
if isinstance(
2636+
value,
2637+
(ast.List, ast.Dict, ast.Set, ast.ListComp, ast.DictComp, ast.SetComp),
2638+
) or (
2639+
isinstance(value, ast.Call)
2640+
and ".".join(compose_call_path(value.func)) in B006_MUTABLE_CALLS
2641+
):
2642+
self.add_error("B046", value)
2643+
26182644
def check_for_b909(self, node: ast.For) -> None:
26192645
if isinstance(node.iter, ast.Name):
26202646
name = _to_name_str(node.iter)
@@ -3127,6 +3153,11 @@ def visit_Lambda(self, node: ast.Lambda) -> None:
31273153
"async_lru.alru_cache",
31283154
"alru_cache",
31293155
}
3156+
B046_DICT_TYPES = {
3157+
"dict",
3158+
"OrderedDict",
3159+
"collections.OrderedDict",
3160+
}
31303161
B902_IMPLICIT_CLASSMETHODS = {"__new__", "__init_subclass__", "__class_getitem__"}
31313162
B902_SELF = ["self"] # it's a list because the first is preferred
31323163
B902_CLS = ["cls", "klass"] # ditto.
@@ -3419,6 +3450,13 @@ def __call__(self, lineno: int, col: int, vars: tuple[object, ...] = ()) -> erro
34193450
"Use a different name for the inner loop variable."
34203451
)
34213452
),
3453+
"B046": Error(
3454+
message=(
3455+
"B046 Mutable value passed to `dict.fromkeys()`. The same object is "
3456+
"shared by every key, so changing it for one key changes it for all of "
3457+
"them. Use a dict comprehension instead, e.g. `{{key: [] for key in keys}}`."
3458+
)
3459+
),
34223460
# Warnings disabled by default.
34233461
"B901": Error(
34243462
message=(

‎tests/eval_files/b046.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import collections
2+
from collections import OrderedDict
3+
4+
keys = ("a", "b")
5+
6+
# bad
7+
dict.fromkeys(keys, []) # B046: 20
8+
dict.fromkeys(keys, {}) # B046: 20
9+
dict.fromkeys(keys, {1, 2}) # B046: 20
10+
dict.fromkeys(keys, [x for x in range(3)]) # B046: 20
11+
dict.fromkeys(keys, {x: x for x in range(3)}) # B046: 20
12+
dict.fromkeys(keys, {x for x in range(3)}) # B046: 20
13+
dict.fromkeys(keys, list()) # B046: 20
14+
dict.fromkeys(keys, dict()) # B046: 20
15+
dict.fromkeys(keys, set()) # B046: 20
16+
dict.fromkeys(keys, collections.defaultdict(list)) # B046: 20
17+
dict.fromkeys(keys, collections.Counter()) # B046: 20
18+
OrderedDict.fromkeys(keys, []) # B046: 27
19+
collections.OrderedDict.fromkeys(keys, []) # B046: 39
20+
OrderedDict.fromkeys(keys, value=[]) # B046: 33
21+
dict.fromkeys(["a", "b"], []) # B046: 26
22+
23+
# good
24+
dict.fromkeys(keys)
25+
dict.fromkeys(keys, None)
26+
dict.fromkeys(keys, 0)
27+
dict.fromkeys(keys, "")
28+
dict.fromkeys(keys, ())
29+
dict.fromkeys(keys, tuple())
30+
dict.fromkeys(keys, frozenset())
31+
dict.fromkeys(keys, value)
32+
dict.fromkeys(keys, make_default())
33+
OrderedDict.fromkeys(keys, value=None)
34+
{key: [] for key in keys}
35+
other.fromkeys(keys, [])

0 commit comments

Comments
 (0)