Skip to content

Commit 95f8791

Browse files
authored
Add B041: Duplicate key-value pairs in dictionary literals (#496)
* b041 duplicate key in dictionary literal * only error if both keys and values are the same * format
1 parent ea13615 commit 95f8791

File tree

4 files changed

+87
-1
lines changed

4 files changed

+87
-1
lines changed

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ second usage. Save the result to a list if the result is needed multiple times.
205205

206206
**B040**: Caught exception with call to ``add_note`` not used. Did you forget to ``raise`` it?
207207

208+
**B041**: Repeated key-value pair in dictionary literal.
209+
208210
Opinionated warnings
209211
~~~~~~~~~~~~~~~~~~~~
210212

bugbear.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import re
99
import sys
1010
import warnings
11-
from collections import defaultdict, namedtuple
11+
from collections import Counter, defaultdict, namedtuple
1212
from contextlib import suppress
1313
from functools import lru_cache, partial
1414
from keyword import iskeyword
@@ -362,6 +362,17 @@ class B040CaughtException:
362362
has_note: bool
363363

364364

365+
class B041UnhandledKeyType:
366+
"""
367+
A dictionary key of a type that we do not check for duplicates.
368+
"""
369+
370+
371+
@attr.define(frozen=True)
372+
class B041VariableKeyType:
373+
name: str
374+
375+
365376
@attr.s
366377
class BugBearVisitor(ast.NodeVisitor):
367378
filename = attr.ib()
@@ -633,6 +644,35 @@ def visit_Set(self, node) -> None:
633644
self.check_for_b033(node)
634645
self.generic_visit(node)
635646

647+
def visit_Dict(self, node) -> None:
648+
self.check_for_b041(node)
649+
self.generic_visit(node)
650+
651+
def check_for_b041(self, node) -> None:
652+
# Complain if there are duplicate key-value pairs in a dictionary literal.
653+
def convert_to_value(item):
654+
if isinstance(item, ast.Constant):
655+
return item.value
656+
elif isinstance(item, ast.Tuple):
657+
return tuple(convert_to_value(i) for i in item.elts)
658+
elif isinstance(item, ast.Name):
659+
return B041VariableKeyType(item.id)
660+
else:
661+
return B041UnhandledKeyType()
662+
663+
keys = [convert_to_value(key) for key in node.keys]
664+
key_counts = Counter(keys)
665+
duplicate_keys = [key for key, count in key_counts.items() if count > 1]
666+
for key in duplicate_keys:
667+
key_indices = [i for i, i_key in enumerate(keys) if i_key == key]
668+
seen = set()
669+
for index in key_indices:
670+
value = convert_to_value(node.values[index])
671+
if value in seen:
672+
key_node = node.keys[index]
673+
self.errors.append(B041(key_node.lineno, key_node.col_offset))
674+
seen.add(value)
675+
636676
def check_for_b005(self, node) -> None:
637677
if isinstance(node, ast.Import):
638678
for name in node.names:
@@ -2327,6 +2367,8 @@ def visit_Lambda(self, node) -> None:
23272367
message="B040 Exception with added note not used. Did you forget to raise it?"
23282368
)
23292369

2370+
B041 = Error(message=("B041 Repeated key-value pair in dictionary literal."))
2371+
23302372
# Warnings disabled by default.
23312373
B901 = Error(
23322374
message=(

tests/b041.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
a = 1
2+
test = {'yes': 1, 'yes': 1}
3+
test = {'yes': 1, 'yes': 1, 'no': 2, 'no': 2}
4+
test = {'yes': 1, 'yes': 1, 'yes': 1}
5+
test = {1: 1, 1.0: 1}
6+
test = {True: 1, True: 1}
7+
test = {None: 1, None: 1}
8+
test = {a: a, a: a}
9+
10+
# no error if either keys or values are different
11+
test = {'yes': 1, 'yes': 2}
12+
test = {1: 1, 2: 1}
13+
test = {(0, 1): 1, (0, 2): 1}
14+
test = {(0, 1): 1, (0, 1): 2}
15+
b = 1
16+
test = {a: a, b: a}
17+
test = {a: a, a: b}
18+
class TestClass:
19+
pass
20+
f = TestClass()
21+
f.a = 1
22+
test = {f.a: 1, f.a: 1}
23+
24+

tests/test_bugbear.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
B037,
4949
B039,
5050
B040,
51+
B041,
5152
B901,
5253
B902,
5354
B903,
@@ -668,6 +669,23 @@ def test_b040(self) -> None:
668669
)
669670
self.assertEqual(errors, expected)
670671

672+
def test_b041(self) -> None:
673+
filename = Path(__file__).absolute().parent / "b041.py"
674+
bbc = BugBearChecker(filename=str(filename))
675+
errors = list(bbc.run())
676+
expected = self.errors(
677+
B041(2, 18),
678+
B041(3, 18),
679+
B041(3, 37),
680+
B041(4, 18),
681+
B041(4, 28),
682+
B041(5, 14),
683+
B041(6, 17),
684+
B041(7, 17),
685+
B041(8, 14),
686+
)
687+
self.assertEqual(errors, expected)
688+
671689
def test_b908(self):
672690
filename = Path(__file__).absolute().parent / "b908.py"
673691
bbc = BugBearChecker(filename=str(filename))

0 commit comments

Comments
 (0)