Repository: mdolab/baseclasses

Issue:
Update `caseInsensitveDict` to preserve initial capitalization
# Description
Currently, keys are cast to lower case on initialization and subsequent addition. We can instead keep those as they are, and just change the string comparison so that they are case insensitive. We should do the same thing for sets also.

Discussion:
nwu63: That is... a really good point. I just assumed I needed both when I wrote this initially but yes the forward mapping is obvious so not needed. I'll push an update.

Patch:
diff --git a/baseclasses/utils.py b/baseclasses/utils.py
index a83a1e9..fa012ae 100644
--- a/baseclasses/utils.py
+++ b/baseclasses/utils.py
@@ -5,32 +5,83 @@ class CaseInsensitiveDict(dict):
     create an instance where keys are not strings.
     All common Python dictionary operations are supported, and additional operations
     can be added easily.
+    In order to preserve capitalization on key initialization, the implementation relies on storing
+    a dictionary of mappings between the lowercase representation and the initial capitalization,
+    which is stored in self.map.
+    By looking up in these mappings, we can check any new keys against existing keys and compare them
+    in a case-insensitive fashion.
     """
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        # convert keys to lower case
-        for k in list(self.keys()):
-            v = super().pop(k)
-            self.__setitem__(k, v)
+        self._updateMap()
+
+    def _updateMap(self):
+        """
+        This function updates self.map based on self.keys().
+        """
+        self.map = {k.lower(): k for k in self.keys()}
+
+    def _getKey(self, key):
+        """
+        This function checks if the input key already exists.
+        Note that this check is case insensitive
+
+        Parameters
+        ----------
+        key : str
+            the key to check
+
+        Returns
+        -------
+        str, None
+            Returns the original key if it exists. Otherwise returns None.
+        """
+        if key.lower() in self.map:
+            return self.map[key.lower()]
+        else:
+            return None
 
     def __setitem__(self, key, value):
-        super().__setitem__(key.lower(), value)
+        existingKey = self._getKey(key)
+        if existingKey:
+            key = existingKey
+        else:
+            self.map[key.lower()] = key
+        super().__setitem__(key, value)
 
     def __getitem__(self, key):
-        return super().__getitem__(key.lower())
+        existingKey = self._getKey(key)
+        if existingKey:
+            key = existingKey
+        return super().__getitem__(key)
 
     def __contains__(self, key):
-        return super().__contains__(key.lower())
+        return key.lower() in self.map.keys()
 
     def __delitem__(self, key):
-        super().__delitem__(key.lower())
+        existingKey = self._getKey(key)
+        if existingKey:
+            key = existingKey
+            self.map.pop(key.lower())
+        super().__delitem__(key)
 
     def pop(self, key, *args, **kwargs):
-        super().pop(key.lower(), *args, **kwargs)
+        existingKey = self._getKey(key)
+        if existingKey:
+            key = existingKey
+            self.map.pop(key.lower())
+        super().pop(key, *args, **kwargs)
 
     def get(self, key, *args, **kwargs):
-        return super().get(key.lower(), *args, **kwargs)
+        existingKey = self._getKey(key)
+        if existingKey:
+            key = existingKey
+        return super().get(key, *args, **kwargs)
+
+    def update(self, d, *args, **kwargs):
+        super().update(d, *args, **kwargs)
+        self._updateMap()
 
 
 class CaseInsensitiveSet(set):
@@ -40,20 +91,78 @@ class CaseInsensitiveSet(set):
     create an instance where elements are not strings.
     All common Python set operations are supported, and additional operations
     can be added easily.
+    In order to preserve capitalization on key initialization, the implementation relies on storing
+    a dictionary of mappings between the lowercase representation and the initial capitalization,
+    which is stored in self.map.
+    By looking up in these mappings, we can check any new keys against existing keys and compare them
+    in a case-insensitive fashion.
     """
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        # convert entries to lowe case
-        for k in self:
-            super().remove(k)
-            self.add(k)
+        self._updateMap()
+
+    def _updateMap(self):
+        self.map = {k.lower(): k for k in list(self)}
+
+    def _getItem(self, item):
+        """
+        This function checks if the input item already exists.
+        Note that this check is case insensitive
+
+        Parameters
+        ----------
+        item : str
+            the item to check
+
+        Returns
+        -------
+        str, None
+            Returns the original item if it exists. Otherwise returns None.
+        """
+        if item.lower() in self.map:
+            return self.map[item.lower()]
+        else:
+            return None
 
     def add(self, item):
-        super().add(item.lower())
+        existingItem = self._getItem(item)
+        if existingItem:
+            item = existingItem
+        else:
+            self.map[item.lower()] = item
+        super().add(item)
+
+    def pop(self, item, *args, **kwargs):
+        existingItem = self._getItem(item)
+        if existingItem:
+            item = existingItem
+            self.map.pop(item.lower())
+        super().pop(item, *args, **kwargs)
+
+    def update(self, d, *args, **kwargs):
+        super().update(d, *args, **kwargs)
+        self._updateMap()
+
+    def issubset(self, other):
+        lowerSelf = set([s.lower() for s in self])
+        lowerOther = set([s.lower() for s in other])
+        return lowerSelf.issubset(lowerOther)
+
+    def remove(self, item):
+        existingItem = self._getItem(item)
+        if existingItem:
+            item = existingItem
+            self.map.pop(item.lower())
+        super().remove(item)
 
     def __contains__(self, item):
-        return super().__contains__(item.lower())
+        return item.lower() in self.map.keys()
+
+    def __eq__(self, other):
+        a = set([s.lower() for s in list(self)])
+        b = set([o.lower() for o in list(other)])
+        return a.__eq__(b)
 
 
 class Error(Exception):


Test patch:
diff --git a/tests/test_BaseSolver.py b/tests/test_BaseSolver.py
index af66b0c..10fe5e2 100644
--- a/tests/test_BaseSolver.py
+++ b/tests/test_BaseSolver.py
@@ -95,10 +95,10 @@ class TestOptions(unittest.TestCase):
         # test Errors
         with self.assertRaises(Error) as context:
             solver.getOption("invalidOption")  # test invalid option in getOption
-        self.assertTrue("intoption" in context.exception.message)  # check that intoption is offered as a suggestion
+        self.assertTrue("intOption" in context.exception.message)  # check that intoption is offered as a suggestion
         with self.assertRaises(Error) as context:
             solver.setOption("invalidOption", 1)  # test invalid option in setOption
-        self.assertTrue("intoption" in context.exception.message)  # check that intoption is offered as a suggestion
+        self.assertTrue("intOption" in context.exception.message)  # check that intoption is offered as a suggestion
         with self.assertRaises(Error):
             solver.setOption("intOption", 4)  # test value not in list
         with self.assertRaises(Error):
diff --git a/tests/test_CaseInsensitve.py b/tests/test_CaseInsensitve.py
index 5fd85e1..ca2e5cc 100644
--- a/tests/test_CaseInsensitve.py
+++ b/tests/test_CaseInsensitve.py
@@ -13,16 +13,19 @@ class TestCaseInsensitiveClasses(unittest.TestCase):
         # test __setitem__
         d["OPTION1"] = value2
         self.assertEqual(d["option1"], value2)
+        self.assertEqual("OPtion1", d.map["option1"])
         # test __contains__
         self.assertIn("Option1", d)
-        d["option2"] = value1
+        d["optioN2"] = value1
         self.assertEqual(len(d), 2)
         self.assertIn("OPTION2", d)
+        # test original capitalization is preserved on new key
+        self.assertEqual("optioN2", d.map["option2"])
         # test pop()
         d.pop("Option2")
         self.assertEqual(len(d), 1)
         self.assertEqual(d.get("opTION1"), value2)
-        self.assertEqual(list(d), ["option1"])
+        self.assertEqual(list(d), ["OPtion1"])
         d2 = CaseInsensitiveDict({"OPTION3": value3})
         # test update()
         d.update(d2)
@@ -33,8 +36,12 @@ class TestCaseInsensitiveClasses(unittest.TestCase):
         # test __contains__ and add()
         s = CaseInsensitiveSet({"Option1"})
         self.assertIn("OPTION1", s)
+        # test original capitalization is preserved on initialization
+        self.assertEqual("Option1", s.map["option1"])
         s.add("OPTION2")
         self.assertIn("option2", s)
+        # test original capitalization is preserved on new item
+        self.assertEqual("OPTION2", s.map["option2"])
         # test update()
         s2 = CaseInsensitiveSet({"OPTION2", "opTION3"})
         s.update(s2)