Repository: sunpy/sunpy

Issue:
Maintain coherence between keycomments and the metadict
See #2748 

This is probably best implemented by adding the functionality to our `MetaDict` object or something, so that we don't have to do it manually everywhere.


Discussion:
Can I pick this up?
@ritesh99rakesh Sure! 
How to contribute ?
Can I work on this issue?
Sure.
The `MetaDict` class is fairly general: it's an `OrderedDict` subclass with case-insensitive `str` keys. Is it okay to have `MetaDict` manage its `KEYCOMMENTS` dictionary (if it has one)? This may introduce knowledge of the FITS header format to `MetaDict`, and I wanted to double-check that this is acceptable before implementing the feature. 

Also, I noticed that `sunpy.io.header.FileHeader` is another general `OrderedDict` subclass used to store header key-value data. Do we also want `FileHeader` to manage `KEYCOMMENTS`?

Resolving patch:
diff --git a/sunpy/util/metadata.py b/sunpy/util/metadata.py
--- a/sunpy/util/metadata.py
+++ b/sunpy/util/metadata.py
@@ -1,6 +1,6 @@
 """
 This module provides a generalized dictionary class that deals with header
-parsing and normalization.
+parsing, normalization, and maintaining coherence between keys and keycomments.
 """
 from collections import OrderedDict
 
@@ -14,28 +14,67 @@ class MetaDict(OrderedDict):
 
     This class handles everything in lower case. This allows case
     insensitive indexing.
+
+    If the key 'keycomments' exists, its value must be a dictionary mapping
+    keys in the `MetaDict` to their comments. The casing of keys in the
+    keycomments dictionary is not significant. If a key is removed from the
+    `MetaDict`, it will also be removed from the keycomments dictionary.
+    Additionally, any extraneous keycomments will be removed when the
+    `MetaDict` is instantiated.
     """
 
     def __init__(self, *args):
         """
-        Creates a new MapHeader instance.
+        Creates a new MetaDict instance.
         """
-        # Store all keys as upper-case to allow for case-insensitive indexing
+        # Store all keys as lower-case to allow for case-insensitive indexing
         # OrderedDict can be instantiated from a list of lists or a tuple of tuples
         tags = dict()
         if args:
             args = list(args)
             adict = args[0]
             if isinstance(adict, list) or isinstance(adict, tuple):
-                tags = OrderedDict((k.upper(), v) for k, v in adict)
+                tags = OrderedDict((k.lower(), v) for k, v in adict)
             elif isinstance(adict, dict):
-                tags = OrderedDict((k.upper(), v) for k, v in adict.items())
+                tags = OrderedDict((k.lower(), v) for k, v in adict.items())
             else:
                 raise TypeError("Can not create a MetaDict from this type input")
             args[0] = tags
 
         super().__init__(*args)
 
+        # Use `copy=True` to avoid mutating the caller's keycomments
+        # dictionary (if they provided one).
+        self._prune_keycomments(copy=True)
+
+    def _prune_keycomments(self, copy=False):
+        """
+        Remove keycomments for keys that are not contained in the MetaDict.
+
+        Parameters
+        ----------
+        copy : `bool`, optional
+            Make a copy of the current keycomments dict before removing keys.
+        """
+        if 'keycomments' not in self:
+            return
+
+        keycomments = self['keycomments']
+
+        if not isinstance(keycomments, dict):
+            raise TypeError(
+                "'keycomments' key must have a value of type `dict`. Found "
+                "the following type: %r" % type(keycomments))
+
+        if copy:
+            keycomments = keycomments.copy()
+
+        for key in list(keycomments.keys()):
+            if key not in self:
+                del keycomments[key]
+
+        self['keycomments'] = keycomments
+
     def __contains__(self, key):
         """
         Override ``__contains__``.
@@ -54,6 +93,15 @@ def __setitem__(self, key, value):
         """
         return OrderedDict.__setitem__(self, key.lower(), value)
 
+    # Note: `OrderedDict.popitem()` does not need to be overridden to prune
+    # keycomments because it calls `__delitem__` internally.
+    def __delitem__(self, key):
+        """
+        Override ``del dict[key]`` key deletion.
+        """
+        OrderedDict.__delitem__(self, key.lower())
+        self._prune_keycomments()
+
     def get(self, key, default=None):
         """
         Override ``.get()`` indexing.
@@ -70,7 +118,11 @@ def pop(self, key, default=None):
         """
         Override ``.pop()`` to perform case-insensitively.
         """
-        return OrderedDict.pop(self, key.lower(), default)
+        has_key = key in self
+        result = OrderedDict.pop(self, key.lower(), default)
+        if has_key:
+            self._prune_keycomments()
+        return result
 
     def update(self, d2):
         """


Test patch:
diff --git a/sunpy/util/tests/test_metadata.py b/sunpy/util/tests/test_metadata.py
--- a/sunpy/util/tests/test_metadata.py
+++ b/sunpy/util/tests/test_metadata.py
@@ -75,6 +75,11 @@ def sea_locations():
             ['LaPteV', 'arctic']]
 
 
+@pytest.fixture
+def seas_metadict(sea_locations):
+    return MetaDict(sea_locations)
+
+
 @pytest.fixture
 def atomic_weights():
     return [['hydrogen', 1],
@@ -84,8 +89,55 @@ def atomic_weights():
 
 
 @pytest.fixture
-def seas_metadict(sea_locations):
-    return MetaDict(sea_locations)
+def atomic_weights_keycomments():
+    """
+    Atomic weights with keycomments (including extraneous keycomments).
+    """
+    return [['hydrogen', 1],
+            ['chromium', 24],
+            ['mercury', 80],
+            ['iridium', 77],
+            ['keycomments', {
+                'chromium': 'Cr',
+                'extra key 1': 'foo',
+                'MERCURY': 'Hg',
+                'extra key 2': 'bar'
+            }]]
+
+
+@pytest.fixture
+def atomic_weights_pruned_keycomments():
+    """
+    Expected result of atomic weights with extraneous keycomments removed.
+    """
+    return [['hydrogen', 1],
+            ['chromium', 24],
+            ['mercury', 80],
+            ['iridium', 77],
+            ['keycomments', {
+                'chromium': 'Cr',
+                'MERCURY': 'Hg'
+            }]]
+
+
+@pytest.fixture
+def atomic_weights_keycomments_after_removal():
+    """
+    Expected result after removing some atomic weights and their keycomments.
+    """
+    return [['chromium', 24],
+            ['iridium', 77],
+            ['keycomments', {
+                'chromium': 'Cr'
+            }]]
+
+
+@pytest.fixture
+def empty_keycomments():
+    """
+    Expected result after removing all keys.
+    """
+    return [['keycomments', {}]]
 
 
 # Test constructors `MetaDict.__init__(...)`
@@ -139,6 +191,20 @@ def test_init_with_metadict(atomic_weights):
     assert id(original) != id(new)
 
 
+def test_init_with_keycomments(atomic_weights_keycomments, atomic_weights_pruned_keycomments):
+    """
+    Initialise `MetaDict` with keycomments. Ensure caller's keycomments dict is not mutated.
+    """
+    orig_dict = pairs_to_dict(atomic_weights_keycomments)
+    orig_keycomments = orig_dict['keycomments'].copy()
+
+    md = MetaDict(orig_dict)
+    check_contents_and_insertion_order(md, atomic_weights_pruned_keycomments)
+
+    assert md['keycomments'] is not orig_dict['keycomments']
+    assert orig_dict['keycomments'] == orig_keycomments
+
+
 def test_init_with_illegal_arg():
     """
     Ensure attempt to initialise with a nonsensical data structure is rejected.
@@ -147,6 +213,14 @@ def test_init_with_illegal_arg():
         MetaDict({'a', 'b', 'c', 'd'})
 
 
+def test_init_with_invalid_keycomments_type():
+    """
+    Ensure attempt to initialise with an invalid keycomments type is rejected.
+    """
+    with pytest.raises(TypeError):
+        MetaDict({'a': 1, 'b': 2, 'keycomments': 3})
+
+
 # Test individual methods
 
 
@@ -193,6 +267,50 @@ def test_setitem_new_entry(seas_metadict):
     assert seas_metadict['Irish'] == 'N.Europe'
 
 
+def test_delitem(seas_metadict):
+    """
+    Test `MetaDict.__delitem__(...)`.
+    """
+    len_before = len(seas_metadict)
+    del seas_metadict['NoRwEgIaN']
+    del seas_metadict['baltic']
+    assert len(seas_metadict) == len_before - 2
+
+    with pytest.raises(KeyError):
+        seas_metadict['baltic']
+
+    with pytest.raises(KeyError):
+        seas_metadict['NoRwEgIaN']
+
+
+def test_delitem_missing_key(seas_metadict):
+    """
+    Test `MetaDict.__delitem__(...)` raises error on missing key.
+    """
+    with pytest.raises(KeyError):
+        del seas_metadict['missing key']
+
+
+def test_delitem_with_keycomments(atomic_weights_keycomments,
+                                  atomic_weights_keycomments_after_removal):
+    """
+    Test `MetaDict.__delitem__(...)` removes corresponding keycomments.
+    """
+    len_before = len(atomic_weights_keycomments)
+    md = MetaDict(atomic_weights_keycomments)
+    del md['hydrogen']
+    del md['mercury']
+    assert len(md) == len_before - 2
+
+    with pytest.raises(KeyError):
+        md['hydrogen']
+
+    with pytest.raises(KeyError):
+        md['mercury']
+
+    check_contents_and_insertion_order(md, atomic_weights_keycomments_after_removal)
+
+
 def test_get(seas_metadict):
     """
     Test `MetaDict.get(...)`
@@ -243,6 +361,32 @@ def test_pop(seas_metadict):
     assert len(seas_metadict) == len_before - 1
 
 
+def test_pop_with_keycomments(atomic_weights_keycomments,
+                              atomic_weights_keycomments_after_removal):
+    """
+    Test `MetaDict.pop(...)` removes corresponding keycomments.
+    """
+    len_before = len(atomic_weights_keycomments)
+    md = MetaDict(atomic_weights_keycomments)
+
+    assert md.pop('hydrogen') == 1
+    assert md.pop('mercury') == 80
+    assert len(md) == len_before - 2
+    check_contents_and_insertion_order(md, atomic_weights_keycomments_after_removal)
+
+
+def test_popitem_with_keycomments(atomic_weights_keycomments, empty_keycomments):
+    """
+    Test `MetaDict.popitem(...)` removes corresponding keycomments.
+    """
+    md = MetaDict(atomic_weights_keycomments)
+
+    assert md.popitem(last=False) == ('hydrogen', 1)
+    assert md.popitem(last=False) == ('chromium', 24)
+    assert md.popitem(last=False) == ('mercury', 80)
+    assert md.popitem(last=False) == ('iridium', 77)
+    check_contents_and_insertion_order(md, empty_keycomments)
+
 #  Test `MetaDict.update(...)`.