From 314b93499629f35b6ac9018b2247c731a6b4e95f Mon Sep 17 00:00:00 2001 From: Hashim1999164 <64767361+Hashim1999164@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:05:54 +0500 Subject: [PATCH] Add attribute method for CMR attribute search parameters --- CHANGELOG.md | 1 + cmr/queries.py | 69 ++++++++++++++++++++++++++++++++++++ tests/test_collection.py | 7 ++++ tests/test_granule.py | 76 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 127b621..17133b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Add `attribute` method on granule and collection queries for CMR `attribute[]` search ([#104](https://github.com/nasa/python_cmr/issues/104)) - Add method `Query.results` for returning results as an iterator instead of sequence ([#37](https://github.com/nasa/python_cmr/issues/37)) ### Changed diff --git a/cmr/queries.py b/cmr/queries.py index 1cf0f69..0b58868 100644 --- a/cmr/queries.py +++ b/cmr/queries.py @@ -795,6 +795,75 @@ def platform(self, platform: str) -> Self: self.params['platform'] = platform return self + def attribute(self, *components: Union[str, FloatLike, Sequence[str]]) -> Self: + """ + Filter by additional attribute using CMR ``attribute[]``. + + Pass a single preformatted CMR attribute string, separate components + (type, name, value and/or range bounds) that are joined with commas, or + a sequence of complete attribute strings. Call more than once to add + further ``attribute[]`` constraints. By default all must match; use + ``option("attribute", "or", True)`` for any match. For range searches, + ``option("attribute", "exclude_boundary", True)`` excludes range + endpoints. For granules, ``option("attribute", "exclude_collection", True)`` + skips collection level attributes. + + Examples: + + .. code:: python + + >>> query = GranuleQuery() + >>> query.attribute("PERCENTAGE") # doctest: +ELLIPSIS + + >>> query.attribute("float", "PERCENTAGE", 25.5) # doctest: +ELLIPSIS + + >>> query.attribute("string", "ID", "cosmic1c1-G25-200703252358") # doctest: +ELLIPSIS + + >>> query.attribute("float", "PERCENTAGE", 25.5, 30) # doctest: +ELLIPSIS + + + When more than one component is given, commas inside each component are + escaped as ``\\,`` per the CMR Search API. + + See `CMR collection additional attribute`_ and + `CMR granule additional attribute`_. + + .. _CMR collection additional attribute: + https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#c-additional-attribute + .. _CMR granule additional attribute: + https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#g-additional-attribute + + :param components: preformatted attribute string, type/name/value parts, + or a sequence of complete attribute strings + :returns: self + """ + + if not components: + raise ValueError("Please provide an attribute name or CMR attribute[] value") + + first = components[0] + if ( + len(components) == 1 + and isinstance(first, (list, tuple)) + and not isinstance(first, (str, bytes)) + ): + values = [str(item) for item in first] + elif len(components) == 1: + values = [str(first)] + else: + escaped = [str(part).replace(",", r"\,") for part in components] + values = [",".join(escaped)] + + if not values or any(not value for value in values): + raise ValueError("Please provide an attribute name or CMR attribute[] value") + + if "attribute" not in self.params: + self.params["attribute"] = [] + + self.params["attribute"].extend(values) + + return self + class GranuleQuery(GranuleCollectionBaseQuery): """ diff --git a/tests/test_collection.py b/tests/test_collection.py index 2c2d866..bfbcabf 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -15,6 +15,13 @@ def _get_cassette_library_dir(self): testdir = os.path.dirname(inspect.getfile(self.__class__)) return os.path.join(testdir, "fixtures", "vcr_cassettes") + def test_attribute(self): + query = CollectionQuery() + query.attribute("float", "PERCENTAGE", 25.5) + + self.assertEqual(query.params["attribute"], ["float,PERCENTAGE,25.5"]) + self.assertIn("attribute[]=float,PERCENTAGE,25.5", query._build_url()) + def test_archive_center(self): query = CollectionQuery() query.archive_center("LP DAAC") diff --git a/tests/test_granule.py b/tests/test_granule.py index fbf188f..015e179 100644 --- a/tests/test_granule.py +++ b/tests/test_granule.py @@ -496,6 +496,82 @@ def test_invalid_mode_constructor(self): with self.assertRaises(ValueError): GranuleQuery(None) # type: ignore[arg-type] + def test_attribute_name_only(self): + query = GranuleQuery() + query.attribute("PERCENTAGE") + + self.assertEqual(query.params["attribute"], ["PERCENTAGE"]) + + def test_attribute_typed_value(self): + query = GranuleQuery() + query.attribute("string", "ID", "cosmic1c1-G25-200703252358") + + self.assertEqual( + query.params["attribute"], + ["string,ID,cosmic1c1-G25-200703252358"], + ) + + def test_attribute_range_and_bounds(self): + query = GranuleQuery() + query.attribute("float", "PERCENTAGE", 25.5, 30) + query.attribute("float", "PERCENTAGE", 25.5, "") + query.attribute("float", "PERCENTAGE", "", 30) + + self.assertEqual( + query.params["attribute"], + [ + "float,PERCENTAGE,25.5,30", + "float,PERCENTAGE,25.5,", + "float,PERCENTAGE,,30", + ], + ) + + def test_attribute_preformatted_and_list(self): + query = GranuleQuery() + query.attribute("float,PERCENTAGE,25.5") + query.attribute(["string,MISSION_NAME,Big Island\\, HI", "PERCENTAGE"]) + + self.assertEqual( + query.params["attribute"], + [ + "float,PERCENTAGE,25.5", + "string,MISSION_NAME,Big Island\\, HI", + "PERCENTAGE", + ], + ) + + def test_attribute_escapes_commas_in_components(self): + query = GranuleQuery() + query.attribute("string", "MISSION_NAME", "Big Island, HI") + + self.assertEqual( + query.params["attribute"], + ["string,MISSION_NAME,Big Island\\, HI"], + ) + + def test_attribute_in_url(self): + query = GranuleQuery() + query.short_name("gnssro_cosmic1_jpl_l1b") + query.attribute("string", "ID", "cosmic1c1-G25-200703252358") + + url = query._build_url() + self.assertIn("attribute[]=string,ID,cosmic1c1-G25-200703252358", url) + + def test_attribute_via_parameters(self): + query = GranuleQuery() + query.parameters(attribute=("string", "ID", "abc")) + + self.assertEqual(query.params["attribute"], ["string,ID,abc"]) + + def test_attribute_empty_rejected(self): + query = GranuleQuery() + + with self.assertRaises(ValueError): + query.attribute() + + with self.assertRaises(ValueError): + query.attribute("") + def test_valid_parameters(self): query = GranuleQuery()