Repository: sunpy/sunpy

Issue:
Accessing the Helioviewer sourceid with fewer keywords.
Currently for the Helioviewer Client, two functions (`get_closest_image` and `download_jp2`) need you to specify 4 keywords to access the `source_id` dictionary. These are `observatory=None, instrument=None, detector=None, measurement=None`.

We want to be able to support a user submitting say two keywords like for example, `observatory=SDO, measurement=171` or `instrument=AIA, measurement=171`.
This is enough information in theory to get the right source_id for **most** of them.
If it can not, it should through a nice error.   


Any change would be inside our functions. This is not related to the API of Helioviewer.
Ideally where we directly index the dictionary for the `source_id`, we would replace that part with something much smarter. What? I don't know.


Discussion:
Hi Can i take this issue?

Sure. This issue partly depends on the currently open pull request #2280.  You will want to check the current structure of the source of dictionary.

I do think this problem is generic and the solution could become a function we call.
@nabobalis  I looked at the documentation and the wrapper helioviewer.py . It seems that the wrapper just takes the parameters and calls the api . The result fetched is shown to the user. 
Is the change required at the API Level?


No, the change would be inside our functions. This is not related to the API.

Ideally where we directly index the dictionary for the sourceid, we would replace that part with something much smarter. 
can i work on this issue?
are there any preset values for the different constants?
What do you mean? The keywords default to none right now. 
I would like to work on this issue
Ok. 
I just wanted to know that we want to access the dictionary using partial key right?
Yes.
Ok

Resolving patch:
diff --git a/sunpy/net/helioviewer.py b/sunpy/net/helioviewer.py
--- a/sunpy/net/helioviewer.py
+++ b/sunpy/net/helioviewer.py
@@ -17,6 +17,7 @@
 from sunpy.time import parse_time
 from sunpy.util.net import download_fileobj
 from sunpy.util.xml import xml_to_dict
+from sunpy.util.util import partial_key_match
 
 __all__ = ['HelioviewerClient']
 
@@ -77,6 +78,8 @@ def get_closest_image(self, date, observatory=None, instrument=None,
                           detector=None, measurement=None, source_id=None):
         """
         Finds the closest image available for the specified source and date.
+        We can use `observatory` and `measurement` or `instrument` and `measurement` to get the value for source ID which
+        can then be used to get required information.
         **This does not download any file.**
 
         This uses `getClosestImage <https://api.helioviewer.org/docs/v2/#OfficialClients>`_ from the Helioviewer API.
@@ -112,13 +115,7 @@ def get_closest_image(self, date, observatory=None, instrument=None,
         2012-01-01T00:00:07.000
         """
         if source_id is None:
-            try:
-                key = (observatory, instrument, detector, measurement)
-                source_id = self.data_sources[key]
-            except KeyError:
-                raise KeyError("The values used for observatory, instrument, detector, measurement "
-                               "do not correspond to a source_id. Please check the list using "
-                               "HelioviewerClient.data_sources.")
+            source_id = self._get_source_id((observatory, instrument, detector, measurement))
 
         params = {
             "action": "getClosestImage",
@@ -137,7 +134,8 @@ def download_jp2(self, date, observatory=None, instrument=None, detector=None,
         """
         Downloads the JPEG 2000 that most closely matches the specified time and
         data source.
-
+        We can use `observatory` and `measurement` or `instrument` and `measurement` to get the value for source ID which
+        can then be used to get required information.
         This uses `getJP2Image <https://api.helioviewer.org/docs/v2/#JPEG2000>`_ from the Helioviewer API.
 
         Parameters
@@ -173,17 +171,13 @@ def download_jp2(self, date, observatory=None, instrument=None, detector=None,
         >>> hv = helioviewer.HelioviewerClient()  # doctest: +REMOTE_DATA
         >>> filepath = hv.download_jp2('2012/07/03 14:30:00', observatory='SDO',
         ...                            instrument='HMI', detector=None, measurement='continuum')  # doctest: +REMOTE_DATA
-        >>> aia = sunpy.map.Map(filepath)  # doctest: +SKIP
+        >>> filepath = hv.download_jp2('2012/07/03 14:30:00', observatory='SDO', measurement='continuum')  # doctest: +REMOTE_DATA
+        >>> filepath = hv.download_jp2('2012/07/03 14:30:00', instrument='HMI', measurement='continuum')  # doctest: +REMOTE_DATA
+        >>> aia = sunpy.map.Map(filepath)  # doctest: +REMOTE_DATA
         >>> aia.peek()  # doctest: +SKIP
         """
         if source_id is None:
-            try:
-                key = (observatory, instrument, detector, measurement)
-                source_id = self.data_sources[key]
-            except KeyError:
-                raise KeyError("The values used for observatory, instrument, detector, measurement "
-                               "do not correspond to a source_id. Please check the list using "
-                               "HelioviewerClient.data_sources.")
+            source_id = self._get_source_id((observatory, instrument, detector, measurement))
 
         params = {
             "action": "getJP2Image",
@@ -197,7 +191,8 @@ def get_jp2_header(self, date, observatory=None, instrument=None, detector=None,
         """
         Get the XML header embedded in a JPEG2000 image. Includes the FITS header as well as a section
         of Helioviewer-specific metadata.
-
+        We can use `observatory` and `measurement` or `instrument` and `measurement` to get the value for source ID which
+        can then be used to get required information.
         This uses `getJP2Header <https://api.helioviewer.org/docs/v2/#JPEG2000>`_ from the Helioviewer API.
 
         Parameters
@@ -235,12 +230,7 @@ def get_jp2_header(self, date, observatory=None, instrument=None, detector=None,
         >>> helioviewer_meta_data = header['helioviewer']  # doctest: +REMOTE_DATA
         """
         if jp2_id is None:
-            try:
-                jp2_id = self.get_closest_image(date, observatory, instrument, detector, measurement)['id']
-            except KeyError:
-                raise KeyError("The values used for observatory, instrument, detector, measurement "
-                               "do not correspond to a source_id. Please check the list using "
-                               "HelioviewerClient.data_sources.")
+            jp2_id = self.get_closest_image(date, observatory, instrument, detector, measurement)['id']
 
         params = {
             "action": "getJP2Header",
@@ -450,3 +440,15 @@ def _request(self, params):
     def _format_date(self, date):
         """Formats a date for Helioviewer API requests"""
         return parse_time(date).isot + "Z"
+
+    def _get_source_id(self, key):
+        """
+        Returns source_id based on the key.
+        """
+        source_id_list = list(partial_key_match(key,self.data_sources))
+        if len(source_id_list) > 1:  # or maybe != 1
+            raise KeyError(f"The values used: {key} do not correspond to one source_id "
+                            f"but {len(source_id_list)} source_id(s)." 
+                            " Please check the list using HelioviewerClient.data_sources.")
+        return source_id_list[0]
+            
diff --git a/sunpy/util/util.py b/sunpy/util/util.py
--- a/sunpy/util/util.py
+++ b/sunpy/util/util.py
@@ -213,3 +213,38 @@ def expand_list_generator(inp):
                 yield nested_item
         else:
             yield item
+
+def partial_key_match(key, dictionary):
+    """
+    Return value/values based on the partial key.
+    Each element of the partial key is matched against the keys of the dictionary and if a match is found 
+    the value of the key is returned.
+    Even a partial match works here i.e even if the key matches partially a value is returned.
+    The function returns a generator.
+
+    Parameters
+    ----------
+    key : `tuple`
+          A tuple containing the partial key.
+    dictionary: `dict`
+          The target dictionary from which we want to retrieve the value based on the partial key.
+
+    Returns
+    -------
+    `list`
+        A list consisting of all the values matching the partial key.
+
+    References
+    ----------
+    * https://stackoverflow.com/questions/18893624/partial-match-dictionary-keyof-tuples-in-python
+
+    Examples
+    ----------
+    >>> d = {('abc','def','ghi') : 1, ('abc', 'def', 'xyz') : 2, ('pqr', 'lmn', 'tuv') : 3}
+    >>> list(partial_key_match(('abc', 'def', None), d))
+        [1, 2]
+
+    """
+    for k, v in dictionary.items():
+        if all(k1 == k2 or k2 is None for k1, k2 in zip(k, key)):
+            yield v


Test patch:
diff --git a/sunpy/util/tests/test_util.py b/sunpy/util/tests/test_util.py
--- a/sunpy/util/tests/test_util.py
+++ b/sunpy/util/tests/test_util.py
@@ -145,3 +145,8 @@ def test_expand_list_generator():
 
     lst = ['a', 'b', [], (['c', 'd']), tuple(), ['e']]
     assert list(util.expand_list_generator(lst)) == ['a', 'b', 'c', 'd', 'e']
+
+def test_partial_key_match():
+    test_dict = {('a','b','c'):(1,2,3), ('a','b','d'):(4,5,6), ('e','f','g'):(8,7,9)}
+    assert list(util.partial_key_match(('a',None,'c'), test_dict))[0] == test_dict[('a','b','c')]
+