Repository: jddeal/python-cmr

Issue:
Add GranuleQuery.collection_id() function
Hi @jddeal we've been using this toolkit... in short _**thank you**_ for publishing it, it;s saved us some time and cleaned up quite a fair bit if code. 
I have a feature request which I would like to discuss with you.
Ideally, we want to search for granules as follows
```
        gq = GranuleQuery()
        gq = gq.collection_id(collection_id)
```
In this user scenario we obtain the collection_id's from the UMM-S = UMM-C associations we curate.

Does adding a function to the GranuleQuery Class make sense?
Thanks


Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 97a61ea..45fc2d5 100644
--- a/README.rst
+++ b/README.rst
@@ -94,6 +94,14 @@ The following methods are available to both collecton and granule queries:
     # only include granules that are unavailable for download
     >>> api.online_only()
 
+    # search for collections/granules associated with or identified by concept IDs
+    # note: often the ECHO collection ID can be used here as well
+    # note: when using CollectionQuery, only collection concept IDs can be passed
+    # note: when uses GranuleQuery, passing a collection's concept ID will filter by granules associated
+    #       with that particular collection.
+    >>> api.concept_id("C1299783579-LPDAAC_ECS")
+    >>> api.concept_id(["G1327299284-LPDAAC_ECS", "G1326330014-LPDAAC_ECS"])
+
 
 Granule searches support these methods (in addition to the shared methods above):
 
diff --git a/cmr/queries.py b/cmr/queries.py
index fb19fa2..7e44fa9 100644
--- a/cmr/queries.py
+++ b/cmr/queries.py
@@ -592,6 +592,25 @@ class GranuleQuery(Query):
         self.params['granule_ur'] = granule_ur
         return self
 
+    def concept_id(self, IDs):
+        """
+        Filter by concept ID (ex: C1299783579-LPDAAC_ECS or G1327299284-LPDAAC_ECS)
+
+        Collections and granules are uniquely identified with this ID. If providing a collection's concept ID
+        here, it will filter by granules associated with that collection. If providing a granule's concept ID
+        here, it will uniquely identify those granules.
+
+        :param IDs: concept ID(s) to search by. Can be provided as a string or list of strings.
+        :returns: Query instance
+        """
+
+        if isinstance(IDs, str):
+            IDs = [IDs]
+        
+        self.params["concept_id"] = IDs
+
+        return self
+
     def _valid_state(self):
 
         # spatial params must be paired with a collection limiting parameter
@@ -646,5 +665,27 @@ class CollectionQuery(Query):
 
         return self
 
+    def concept_id(self, IDs):
+        """
+        Filter by concept ID (ex: C1299783579-LPDAAC_ECS)
+
+        Collections are uniquely identified with this ID.
+
+        :param IDs: concept ID(s) to search by. Can be provided as a string or list of strings.
+        :returns: Query instance
+        """
+
+        if isinstance(IDs, str):
+            IDs = [IDs]
+        
+        # verify we weren't provided any granule concept IDs
+        for ID in IDs:
+            if ID.strip()[0] != "C":
+                raise ValueError("Only collection concept ID's can be provided (begin with 'C'): {}".format(ID))
+        
+        self.params["concept_id"] = IDs
+
+        return self
+
     def _valid_state(self):
         return True
diff --git a/requirements.txt b/requirements.txt
index 0feba0e..2351d9c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,7 @@
-appdirs==1.4.0
-astroid==1.4.9
-isort==4.2.5
-lazy-object-proxy==1.2.2
-mccabe==0.5.3
-packaging==16.8
-pylint==1.6.4
-pyparsing==2.1.10
-requests==2.12.4
-six==1.10.0
-wrapt==1.10.8
+certifi==2019.6.16
+chardet==3.0.4
+idna==2.8
+pkg-resources==0.0.0
+python-cmr==0.3.3
+requests==2.22.0
+urllib3==1.25.3


Test patch:
diff --git a/tests/test_collection.py b/tests/test_collection.py
index 3241e79..afdaf51 100644
--- a/tests/test_collection.py
+++ b/tests/test_collection.py
@@ -36,3 +36,22 @@ class TestCollectionClass(unittest.TestCase):
             query.format("invalid")
             query.format("jsonn")
             query.format("iso19116")
+    
+    def test_valid_concept_id(self):
+        query = CollectionQuery()
+
+        query.concept_id("C1299783579-LPDAAC_ECS")
+        self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS"])
+        
+        query.concept_id(["C1299783579-LPDAAC_ECS", "C1441380236-PODAAC"])
+        self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS", "C1441380236-PODAAC"])
+    
+    def test_invalid_concept_id(self):
+        query = CollectionQuery()
+
+        with self.assertRaises(ValueError):
+            query.concept_id("G1327299284-LPDAAC_ECS")
+        
+        with self.assertRaises(ValueError):
+            query.concept_id(["C1299783579-LPDAAC_ECS", "G1327299284-LPDAAC_ECS"])
+
diff --git a/tests/test_granule.py b/tests/test_granule.py
index 190f6cc..e4bb297 100644
--- a/tests/test_granule.py
+++ b/tests/test_granule.py
@@ -419,3 +419,13 @@ class TestGranuleClass(unittest.TestCase):
         url = query._build_url()
         self.assertNotIn("True", url)
         self.assertNotIn("False", url)
+    
+    def test_valid_concept_id(self):
+        query = GranuleQuery()
+
+        query.concept_id("C1299783579-LPDAAC_ECS")
+        self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS"])
+        
+        query.concept_id(["C1299783579-LPDAAC_ECS", "G1441380236-PODAAC"])
+        self.assertEqual(query.params["concept_id"], ["C1299783579-LPDAAC_ECS", "G1441380236-PODAAC"])
+