Repository: isi-vista/immutablecollections

Issue:
Provide ImmutableDict & ImmutableMultiDict factory methods which ban duplicate additions
Branching off #49, in what manner should duplicates be banned from dicts and multidicts?

Some potential options:

* Error only when `key1 == key2` and `value1 == value2` (I think this one)
* Error only when `key1 == key2` but `value1 != value2`
* Error when `key1 == key2` regardless of values

I'm assuming we want both a new constructor and a new kwarg for each data type. Something like constructor `immutabledict_from_unique_BLANK()` and kwarg `forbid_duplicate_BLANK`  where `BLANK` is either `keys` or `items`.

Comments:

Patch:
diff --git a/immutablecollections/__init__.py b/immutablecollections/__init__.py
index af96c53..d987edb 100644
--- a/immutablecollections/__init__.py
+++ b/immutablecollections/__init__.py
@@ -26,7 +26,11 @@ frozenset([1, 2, 3]), both of which are equal to the regular set {1, 2, 3}.
 from attr.exceptions import FrozenInstanceError
 
 from immutablecollections.immutablecollection import ImmutableCollection
-from immutablecollections._immutabledict import immutabledict, ImmutableDict
+from immutablecollections._immutabledict import (
+    immutabledict,
+    ImmutableDict,
+    immutabledict_from_unique_keys,
+)
 from immutablecollections._immutableset import (
     immutableset,
     immutableset_from_unique_elements,
diff --git a/immutablecollections/_immutabledict.py b/immutablecollections/_immutabledict.py
index 924c46a..e238a12 100644
--- a/immutablecollections/_immutabledict.py
+++ b/immutablecollections/_immutabledict.py
@@ -5,6 +5,8 @@ from typing import (
     Mapping,
     TypeVar,
     Tuple,
+    Set,
+    List,
     Iterator,
     Optional,
     Union,
@@ -32,7 +34,7 @@ InstantiationTypes = (Mapping, Iterable)  # pylint:disable=invalid-name
 
 
 def immutabledict(
-    iterable: Optional[AllowableSourceType] = None
+    iterable: Optional[AllowableSourceType] = None, *, forbid_duplicate_keys: bool = False
 ) -> "ImmutableDict[KT, VT]":
     """
     Create an immutable dictionary with the given mappings.
@@ -51,7 +53,8 @@ def immutabledict(
 
     if isinstance(iterable, ImmutableDict):
         # if an ImmutableDict is input, we can safely just return it,
-        # since the object can safely be shared
+        # since the object can safely be shared.
+        # It is also guaranteed not to have repeat keys
         return iterable
 
     if isinstance(iterable, Dict) and not DICT_ITERATION_IS_DETERMINISTIC:
@@ -66,6 +69,29 @@ def immutabledict(
             f"Cannot create an immutabledict from {type(iterable)}, only {InstantiationTypes}"
         )
 
+    if forbid_duplicate_keys:
+        keys: List[KT]
+        # `for x in dict` grabs keys, but `for x in pairs` grabs pairs, so we must branch
+        if isinstance(iterable, Mapping):
+            # duplicate keys are possible if input is e.g. a multidict
+            keys = list(iterable.keys())
+        else:
+            # iterable must be a (key, value) pair iterable
+            iterable = list(iterable)  # in case iterable is consumed by iteration
+            keys = [key for key, value in iterable]
+        seen: Set[KT] = set()
+        duplicated: Set[KT] = set()
+        for key in keys:
+            if key in seen:
+                duplicated.add(key)
+            else:
+                seen.add(key)
+        if duplicated:
+            raise ValueError(
+                "forbid_duplicate_keys=True, but some keys "
+                "occur multiple times in input: {}".format(duplicated)
+            )
+
     ret: ImmutableDict[KT, VT] = _RegularDictBackedImmutableDict(iterable)
     if ret:
         return ret
@@ -73,6 +99,16 @@ def immutabledict(
         return _EMPTY
 
 
+def immutabledict_from_unique_keys(
+    iterable: Optional[AllowableSourceType] = None
+) -> "ImmutableDict[KT, VT]":
+    """
+    Create an immutable dictionary with the given mappings, but raise ValueError if
+    *iterable* contains the same item twice. More information in `immutabledict`
+    """
+    return immutabledict(iterable, forbid_duplicate_keys=True)
+
+
 class ImmutableDict(ImmutableCollection[KT], Mapping[KT, VT], metaclass=ABCMeta):
     """
     A ``Mapping`` implementation which is locally immutable.
diff --git a/immutablecollections/_immutableset.py b/immutablecollections/_immutableset.py
index 593b362..aec7731 100644
--- a/immutablecollections/_immutableset.py
+++ b/immutablecollections/_immutableset.py
@@ -92,6 +92,7 @@ def immutableset(
                 "iteration order, specify disable_order_check=True"
             )
 
+    duplicated = set()  # only used if forbid_duplicate_elements=True
     iteration_order = []
     containment_set: MutableSet[T] = set()
     for value in iterable:
@@ -99,9 +100,13 @@ def immutableset(
             containment_set.add(value)
             iteration_order.append(value)
         elif forbid_duplicate_elements:
-            raise ValueError(
-                "Input collection has duplicate items and forbid_duplicate_elements=False"
-            )
+            duplicated.add(value)
+
+    if forbid_duplicate_elements and duplicated:
+        raise ValueError(
+            "forbid_duplicate_elements=True, but some elements "
+            "occur multiple times in input: {}".format(duplicated)
+        )
 
     if iteration_order:
         if len(iteration_order) == 1:


Test patch:
diff --git a/tests/test_immutabledict.py b/tests/test_immutabledict.py
index 41dfab2..84c97ff 100644
--- a/tests/test_immutabledict.py
+++ b/tests/test_immutabledict.py
@@ -2,7 +2,11 @@ import pickle
 from collections.abc import Mapping
 from unittest import TestCase
 
-from immutablecollections import immutabledict, ImmutableDict
+from immutablecollections import (
+    immutabledict,
+    ImmutableDict,
+    immutabledict_from_unique_keys,
+)
 
 
 class TestImmutableDict(TestCase):
@@ -138,3 +142,20 @@ class TestImmutableDict(TestCase):
             immutabledict([(5, "apple"), (2, "banana")]).__reduce__(),
             (immutabledict, (((5, "apple"), (2, "banana")),)),
         )
+
+    def test_immutabledict_duplication_blocking(self):
+        bad = [(7, 8), (9, 10), (7, 11)]
+        with self.assertRaises(ValueError):
+            immutabledict(bad, forbid_duplicate_keys=True)
+        with self.assertRaises(ValueError):
+            immutabledict_from_unique_keys(bad)
+        with self.assertRaises(ValueError):
+            immutabledict((x for x in bad), forbid_duplicate_keys=True)
+        with self.assertRaises(ValueError):
+            immutabledict_from_unique_keys(x for x in bad)
+
+        good = [(7, 8), (9, 10), (12, 11)]
+        immutabledict(good, forbid_duplicate_keys=True)
+        immutabledict_from_unique_keys(good)
+        immutabledict((x for x in good), forbid_duplicate_keys=True)
+        immutabledict_from_unique_keys(x for x in good)
diff --git a/tests/test_immutableset.py b/tests/test_immutableset.py
index ead7932..591a8e9 100644
--- a/tests/test_immutableset.py
+++ b/tests/test_immutableset.py
@@ -286,11 +286,18 @@ class TestImmutableSet(TestCase):
         self.assertEqual({"a"}, frozenset(["a", "b"]) - immutableset(["b", "c"]))
 
     def test_forbid_duplicate_elements(self):
+        bad = [4, 6, 7, 9, 7]
         with self.assertRaises(ValueError):
-            immutableset([4, 6, 7, 9, 7], forbid_duplicate_elements=True)
-        immutableset([3, 7, 5, 9], forbid_duplicate_elements=True)
-
-    def test_immutableset_from_unique_elements(self):
+            immutableset(bad, forbid_duplicate_elements=True)
+        with self.assertRaises(ValueError):
+            immutableset_from_unique_elements(bad)
         with self.assertRaises(ValueError):
-            immutableset_from_unique_elements([4, 6, 7, 9, 7])
-        immutableset_from_unique_elements([3, 7, 5, 9])
+            immutableset((x for x in bad), forbid_duplicate_elements=True)
+        with self.assertRaises(ValueError):
+            immutableset_from_unique_elements(x for x in bad)
+
+        good = [3, 7, 5, 9]
+        immutableset(good, forbid_duplicate_elements=True)
+        immutableset_from_unique_elements(good)
+        immutableset((x for x in good), forbid_duplicate_elements=True)
+        immutableset_from_unique_elements(x for x in good)