Repository: sunpy/sunpy

Issue:
VSO fails to filter query response.
<!-- This comments are hidden when you submit the issue so you do not need to remove them!
Please be sure to check out our contributing guidelines: https://github.com/sunpy/sunpy/blob/master/CONTRIBUTING.rst
Please be sure to check out our code of conduct:
https://github.com/sunpy/sunpy/blob/master/CODE_OF_CONDUCT.rst -->

<!-- Please have a search on our GitHub repository to see if a similar issue has already been posted.
If a similar issue is closed, have a quick look to see if you are satisfied by the resolution.
If not please go ahead and open an issue! -->

### Description
<!-- Provide a general description of the bug. -->
On playing with VSO's post search filtering code, I came across a bug that involves failure of hashing of query response blocks. 

### Expected behavior
<!-- What did you expect to happen. -->
The QueryResponse would return a filtered response according to the new attributes provided.

### Actual behavior
<!-- What actually happened. -->
<!-- Was the output confusing or poorly described? -->
`filter_results` in `net/vso/attrs` returns an error. See gist link for more details. 

### Steps to Reproduce
<!-- Ideally a code example could be provided so we can run it ourselves. -->
<!-- If you are pasting code, use tripe backticks (```) around your code snippet. -->
This gist - https://gist.github.com/aribalam/e46c0647a3254ed0986eb2a689cb99ad.
Apparently specifying post search attributes using & creates this error.

### System Details
I cannot provide any more additional details. 

### Possible Solutions
We could implement a `__hash__` function in `QueryResponseBlock` class in `net/dataretriever/client`. We could also avoid converting the response list into set assuming no two QueryResponseBlock will be same.


Discussion:
https://github.com/mvantellingen/python-zeep/issues/1075 I created an issue on zeep repo too, showing how we can't make a set from zeep objects since they are unhashable.

Resolving patch:
diff --git a/sunpy/net/vso/attrs.py b/sunpy/net/vso/attrs.py
--- a/sunpy/net/vso/attrs.py
+++ b/sunpy/net/vso/attrs.py
@@ -320,11 +320,13 @@ def _(attr, results):
         if
         it.wave.wavemax is not None
         and
-        attr.min <= it.wave.wavemax.to(u.angstrom, equivalencies=u.spectral())
+        attr.min <= u.Quantity(it.wave.wavemax, unit=it.wave.waveunit).to(
+            u.angstrom, equivalencies=u.spectral())
         and
         it.wave.wavemin is not None
         and
-        attr.max >= it.wave.wavemin.to(u.angstrom, equivalencies=u.spectral())
+        attr.max >= u.Quantity(it.wave.wavemin, unit=it.wave.waveunit).to(
+            u.angstrom, equivalencies=u.spectral())
     }
 
 
diff --git a/sunpy/net/vso/vso.py b/sunpy/net/vso/vso.py
--- a/sunpy/net/vso/vso.py
+++ b/sunpy/net/vso/vso.py
@@ -54,6 +54,40 @@
 RANGE = re.compile(r'(\d+)(\s*-\s*(\d+))?(\s*([a-zA-Z]+))?')
 
 
+class HashableResponse:
+    """
+    Enables hashing of objects returned from `zeep.Client` using the ``fileid`` of a "record item".
+
+    Parameters
+    ----------
+    d : `dict`
+        A "QueryResponseBlock" returned from the `sunpy.net.vso.VSOClient`.
+
+    References
+    ----------
+    * https://stackoverflow.com/a/1305682
+    """
+    def __init__(self, d):
+        self.data = d
+        for a, b in d.items():
+            if isinstance(b, (list, tuple)):
+                setattr(self, a, [HashableResponse(x) if isinstance(x, dict) else x for x in b])
+            else:
+                setattr(self, a, HashableResponse(b) if isinstance(b, dict) else b)
+
+    def __hash__(self):
+        return hash(self.fileid)
+
+    def __eq__(self, other):
+        return self.__hash__() == other.__hash__()
+
+    def __str__(self):
+        return self.data.__str__()
+
+    def __repr__(self):
+        return self.data.__repr__()
+
+
 class _Str(str):
 
     """ Subclass of string that contains a meta attribute for the
@@ -219,8 +253,12 @@ def search(self, *query):
 
     @classmethod
     def create(cls, queryresult):
-        res = list(iter_sort_response(queryresult))
-        return cls(res, queryresult)
+        unhashed_recs = iter_sort_response(queryresult)
+        hashed_recs = list()
+        for r in unhashed_recs:
+            res_dict = json.loads(json.dumps(serialize_object(r)))
+            hashed_recs.append(HashableResponse(res_dict))
+        return cls(hashed_recs, queryresult)
 
     def total_size(self):
         """ Total size of data in KB. May be less than the actual
@@ -481,7 +519,7 @@ def mk_filename(pattern, queryresponse, resp, url):
         if not name:
             name = f"vso_file_{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}"
 
-        fname = pattern.format(file=name, **serialize_object(queryresponse))
+        fname = pattern.format(file=name, **(queryresponse.data))
 
         return fname
 


Test patch:
diff --git a/sunpy/net/vso/tests/test_vso.py b/sunpy/net/vso/tests/test_vso.py
--- a/sunpy/net/vso/tests/test_vso.py
+++ b/sunpy/net/vso/tests/test_vso.py
@@ -12,7 +12,7 @@
 from sunpy.net import vso
 from sunpy.net.vso import QueryResponse
 from sunpy.net.vso import attrs as va
-from sunpy.net.vso.vso import VSOClient, build_client, get_online_vso_url
+from sunpy.net.vso.vso import HashableResponse, VSOClient, build_client, get_online_vso_url
 from sunpy.tests.mocks import MockObject
 from sunpy.time import TimeRange, parse_time
 
@@ -501,6 +501,19 @@ def test_build_client_params():
         build_client(url="http://notathing.com/")
 
 
+@pytest.mark.remote_data
+def test_vso_post_search(client):
+    timerange = a.Time(('2020-01-01 00:01:05'), ('2020-01-01 00:01:10'))
+    results = client.search(timerange, a.Instrument('aia') | a.Instrument('hmi'))
+    trange_filter = a.Time(('2020-01-01 00:01:05'), ('2020-01-01 00:01:07'))
+    wave_filter = a.Wavelength(131 * u.Angstrom)
+    res_filtered = results.search(trange_filter & a.Instrument('aia') & wave_filter)
+    for rec in res_filtered:
+        assert parse_time(rec.time.start) <= parse_time(trange_filter.end)
+        assert rec.instrument.lower() == 'aia'
+        assert (float(rec.wave.wavemax) == 131.0 or float(rec.wave.wavemin) == 131.0)
+
+
 @pytest.mark.remote_data
 def test_incorrect_content_disposition(client):
     results = client.search(
@@ -555,3 +568,19 @@ def test_response_block_properties(client):
                         a.Sample(10 * u.minute))
     properties = res.response_block_properties()
     assert len(properties) == 0
+
+
+def test_HashableResponse():
+    d0 = {'instrument': 'HMI', 'wave': {'wavemin': '6173', 'wavemax': '6174'}, 'fileid': 'fid1'}
+    d1 = {'instrument': 'AIA', 'wave': {'wavemin': '304', 'wavemax': '304'}, 'fileid': 'fid1'}
+    d2 = {'instrument': 'AIA', 'wave': {'wavemin': '131', 'wavemax': '131'}, 'fileid': 'fid2'}
+    dicts = [d0, d1, d2]
+    hashRecs = list()
+    for d in dicts:
+        hashRecs.append(HashableResponse(d))
+    assert d0['wave']['wavemax'] == hashRecs[0].wave.wavemax == '6174'
+    assert d2['fileid'] == hashRecs[2].fileid
+    assert hashRecs[0].__eq__(hashRecs[2]) is False
+    assert hashRecs[0] == hashRecs[1]
+    assert hashRecs[1].__hash__() != hashRecs[2].__hash__()
+    assert len(set(hashRecs)) == 2