diff --git a/README.rst b/README.rst index 54bdbc2..188e4cd 100644 --- a/README.rst +++ b/README.rst @@ -205,6 +205,8 @@ second usage. Save the result to a list if the result is needed multiple times. **B040**: Caught exception with call to ``add_note`` not used. Did you forget to ``raise`` it? +**B041**: Repeated key-value pair in dictionary literal. + Opinionated warnings ~~~~~~~~~~~~~~~~~~~~ diff --git a/bugbear.py b/bugbear.py index 2519a24..25e160e 100644 --- a/bugbear.py +++ b/bugbear.py @@ -8,7 +8,7 @@ import re import sys import warnings -from collections import defaultdict, namedtuple +from collections import Counter, defaultdict, namedtuple from contextlib import suppress from functools import lru_cache, partial from keyword import iskeyword @@ -362,6 +362,17 @@ class B040CaughtException: has_note: bool +class B041UnhandledKeyType: + """ + A dictionary key of a type that we do not check for duplicates. + """ + + +@attr.define(frozen=True) +class B041VariableKeyType: + name: str + + @attr.s class BugBearVisitor(ast.NodeVisitor): filename = attr.ib() @@ -633,6 +644,35 @@ def visit_Set(self, node) -> None: self.check_for_b033(node) self.generic_visit(node) + def visit_Dict(self, node) -> None: + self.check_for_b041(node) + self.generic_visit(node) + + def check_for_b041(self, node) -> None: + # Complain if there are duplicate key-value pairs in a dictionary literal. + def convert_to_value(item): + if isinstance(item, ast.Constant): + return item.value + elif isinstance(item, ast.Tuple): + return tuple(convert_to_value(i) for i in item.elts) + elif isinstance(item, ast.Name): + return B041VariableKeyType(item.id) + else: + return B041UnhandledKeyType() + + keys = [convert_to_value(key) for key in node.keys] + key_counts = Counter(keys) + duplicate_keys = [key for key, count in key_counts.items() if count > 1] + for key in duplicate_keys: + key_indices = [i for i, i_key in enumerate(keys) if i_key == key] + seen = set() + for index in key_indices: + value = convert_to_value(node.values[index]) + if value in seen: + key_node = node.keys[index] + self.errors.append(B041(key_node.lineno, key_node.col_offset)) + seen.add(value) + def check_for_b005(self, node) -> None: if isinstance(node, ast.Import): for name in node.names: @@ -2327,6 +2367,8 @@ def visit_Lambda(self, node) -> None: message="B040 Exception with added note not used. Did you forget to raise it?" ) +B041 = Error(message=("B041 Repeated key-value pair in dictionary literal.")) + # Warnings disabled by default. B901 = Error( message=( diff --git a/tests/b041.py b/tests/b041.py new file mode 100644 index 0000000..ae819f4 --- /dev/null +++ b/tests/b041.py @@ -0,0 +1,24 @@ +a = 1 +test = {'yes': 1, 'yes': 1} +test = {'yes': 1, 'yes': 1, 'no': 2, 'no': 2} +test = {'yes': 1, 'yes': 1, 'yes': 1} +test = {1: 1, 1.0: 1} +test = {True: 1, True: 1} +test = {None: 1, None: 1} +test = {a: a, a: a} + +# no error if either keys or values are different +test = {'yes': 1, 'yes': 2} +test = {1: 1, 2: 1} +test = {(0, 1): 1, (0, 2): 1} +test = {(0, 1): 1, (0, 1): 2} +b = 1 +test = {a: a, b: a} +test = {a: a, a: b} +class TestClass: + pass +f = TestClass() +f.a = 1 +test = {f.a: 1, f.a: 1} + + diff --git a/tests/test_bugbear.py b/tests/test_bugbear.py index bdc2552..e702a2d 100644 --- a/tests/test_bugbear.py +++ b/tests/test_bugbear.py @@ -48,6 +48,7 @@ B037, B039, B040, + B041, B901, B902, B903, @@ -666,6 +667,23 @@ def test_b040(self) -> None: ) self.assertEqual(errors, expected) + def test_b041(self) -> None: + filename = Path(__file__).absolute().parent / "b041.py" + bbc = BugBearChecker(filename=str(filename)) + errors = list(bbc.run()) + expected = self.errors( + B041(2, 18), + B041(3, 18), + B041(3, 37), + B041(4, 18), + B041(4, 28), + B041(5, 14), + B041(6, 17), + B041(7, 17), + B041(8, 14), + ) + self.assertEqual(errors, expected) + def test_b908(self): filename = Path(__file__).absolute().parent / "b908.py" bbc = BugBearChecker(filename=str(filename))