diff --git a/deepmerge/extended_set.py b/deepmerge/extended_set.py index 63b6e82..a692c01 100644 --- a/deepmerge/extended_set.py +++ b/deepmerge/extended_set.py @@ -1,4 +1,4 @@ -from typing import Sequence, Any +from typing import Sequence, Any, Dict, List class ExtendedSet(set): @@ -13,10 +13,18 @@ class ExtendedSet(set): """ def __init__(self, elements: Sequence) -> None: - self._values_by_hash = {self._hash_element(e): e for e in elements} + # Elements are grouped into buckets keyed by their (possibly lossy) + # hash. Membership and insertion compare a candidate against the + # members of the matching bucket using equality, so two distinct + # elements that merely collide on the hash are not treated as equal. + self._buckets: Dict[int, List[Any]] = {} + for element in elements: + self._insert(element) def _insert(self, element: Any) -> None: - self._values_by_hash[self._hash_element(element)] = element + bucket = self._buckets.setdefault(self._hash_element(element), []) + if not any(element == existing for existing in bucket): + bucket.append(element) return def _hash_element(self, element: Any) -> int: @@ -29,4 +37,7 @@ def _hash_element(self, element: Any) -> int: return hash(str(element)) def __contains__(self, obj: Any) -> bool: - return self._hash_element(obj) in self._values_by_hash + bucket = self._buckets.get(self._hash_element(obj)) + if bucket is None: + return False + return any(obj == existing for existing in bucket) diff --git a/deepmerge/strategy/fallback.py b/deepmerge/strategy/fallback.py index 7cfbaaa..109b21c 100644 --- a/deepmerge/strategy/fallback.py +++ b/deepmerge/strategy/fallback.py @@ -5,7 +5,6 @@ import deepmerge.merger from .core import StrategyList - T = TypeVar("T") diff --git a/deepmerge/tests/strategy/test_list.py b/deepmerge/tests/strategy/test_list.py index 7211757..db26d58 100644 --- a/deepmerge/tests/strategy/test_list.py +++ b/deepmerge/tests/strategy/test_list.py @@ -43,3 +43,23 @@ def test_strategy_append_similar_dict(custom_merger): result = custom_merger.merge(base, nxt) assert result == [{"bar": "bob", "foo": "baz"}, {"x": "y"}] + + +def test_strategy_append_unique_keeps_hash_colliding_dicts(custom_merger): + """append_unique must keep distinct dicts even when their lossy + "key:value" hash collides (e.g. ``{"a": "1", "b": "2"}`` and + ``{"a": "1,b:2"}`` both hash to ``"a:1,b:2"``), and must not treat an + ``int`` value as equal to its ``str`` form. + """ + assert custom_merger.merge([{"a": "1", "b": "2"}], [{"a": "1,b:2"}]) == [ + {"a": "1", "b": "2"}, + {"a": "1,b:2"}, + ] + assert custom_merger.merge([{"k": 1}], [{"k": "1"}]) == [{"k": 1}, {"k": "1"}] + + +def test_strategy_append_unique_hashable_hash_collision(custom_merger): + """A hashable element must not be considered present just because a + different element shares its hash (e.g. ``hash(-1) == hash(-2)``). + """ + assert custom_merger.merge([-1], [-2]) == [-1, -2]