33"""
44
55from abc import abstractmethod
6+ from collections import defaultdict
67from datetime import date , datetime , timezone
78from inspect import getmembers , ismethod
89from re import search
@@ -46,12 +47,12 @@ class Query:
4647 _format = "json"
4748 _valid_formats_regex = [
4849 "json" , "xml" , "echo10" , "iso" , "iso19115" ,
49- "csv" , "atom" , "kml" , "native"
50+ "csv" , "atom" , "kml" , "native" , "stac" ,
5051 ]
5152
5253 def __init__ (self , route : str , mode : str = CMR_OPS ):
5354 self .params : MutableMapping [str , Any ] = {}
54- self .options : MutableMapping [str , Any ] = {}
55+ self .options : MutableMapping [str , MutableMapping [ str , Any ]] = defaultdict ( dict )
5556 self ._route = route
5657 self .mode (mode )
5758 self .concept_id_chars : Set [str ] = set ()
@@ -190,8 +191,10 @@ def _build_url(self) -> str:
190191 if isinstance (val , list ):
191192 for list_val in val :
192193 formatted_params .append (f"{ key } []={ list_val } " )
194+
193195 elif isinstance (val , bool ):
194196 formatted_params .append (f"{ key } ={ str (val ).lower ()} " )
197+
195198 else :
196199 formatted_params .append (f"{ key } ={ val } " )
197200
@@ -201,13 +204,6 @@ def _build_url(self) -> str:
201204 formatted_options : List [str ] = []
202205 for param_key in self .options :
203206 for option_key , val in self .options [param_key ].items ():
204-
205- # all CMR options must be booleans
206- if not isinstance (val , bool ):
207- raise TypeError (
208- f"parameter '{ param_key } ' with option '{ option_key } ' must be a boolean"
209- )
210-
211207 formatted_options .append (f"options[{ param_key } ][{ option_key } ]={ str (val ).lower ()} " )
212208
213209 options_as_string = "&" .join (formatted_options )
@@ -312,6 +308,62 @@ def bearer_token(self, bearer_token: str) -> Self:
312308
313309 return self
314310
311+ def option (
312+ self , parameter : str , key : str , value : Union [str , bool , int , float , None ]
313+ ) -> Self :
314+ """
315+ Set or remove a search parameter option.
316+
317+ If either an empty parameter name or option key is given, do nothing.
318+ Otherwise, if a non-`None` option value is given, set the specified parameter
319+ option to the value; else _remove_ the parameter option, if previously given.
320+
321+ In all cases, return self to support method chaining.
322+
323+ See `CMR Search API Parameter Options`_ as well as other sections of the
324+ documentation that describe other available parameter options.
325+
326+ .. _CMR Search API Parameter Options:
327+ https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#parameter-options
328+
329+ Example:
330+
331+ .. code:: python
332+
333+ >>> query = CollectionQuery()
334+ >>> query.option("short_name", "ignore_case", True) # doctest: +ELLIPSIS
335+ <cmr.queries.CollectionQuery ...>
336+ >>> query.options # doctest: +ELLIPSIS
337+ defaultdict(..., {'short_name': {'ignore_case': True}})
338+ >>> query.option("short_name", "ignore_case", False) # doctest: +ELLIPSIS
339+ <cmr.queries.CollectionQuery ...>
340+ >>> query.options # doctest: +ELLIPSIS
341+ defaultdict(..., {'short_name': {'ignore_case': False}})
342+ >>> (query
343+ ... .option("short_name", "ignore_case", None) # remove an option
344+ ... .option("short_name", "or", True)
345+ ... .option("highlights", "begin_tag", "<b>")
346+ ... .option("highlights", "end_tag", "</b>")
347+ ... )
348+ <cmr.queries.CollectionQuery ...>
349+ >>> query.options # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
350+ defaultdict(..., {'short_name': {'or': True},
351+ 'highlights': {'begin_tag': '<b>', 'end_tag': '</b>'}})
352+
353+ :param parameter: search parameter to set an option for
354+ :param key: option key to set a value for
355+ :param value: value to set for the option, or `None` to remove the option
356+ :returns: self
357+ """
358+
359+ if parameter and key :
360+ if value is None :
361+ del self .options [parameter ][key ]
362+ else :
363+ self .options [parameter ][key ] = value
364+
365+ return self
366+
315367
316368class GranuleCollectionBaseQuery (Query ):
317369 """
@@ -345,12 +397,12 @@ def _format_date(
345397 ) -> Tuple [str , str ]:
346398 """
347399 Format dates into expected format for date queries.
348-
400+
349401 :param date_from: earliest date of temporal range
350402 :param date_to: latest date of temporal range
351403 :returns: Tuple instance
352404 """
353-
405+
354406 iso_8601 = "%Y-%m-%dT%H:%M:%SZ"
355407
356408 # process each date into a datetime object
@@ -390,7 +442,7 @@ def convert_to_string(date: Optional[DateLike], default: datetime) -> str:
390442 # if we have both dates, make sure from isn't later than to
391443 if date_from and date_to and date_from > date_to :
392444 raise ValueError ("date_from must be earlier than date_to." )
393-
445+
394446 return date_from , date_to
395447
396448 def revision_date (
@@ -410,7 +462,7 @@ def revision_date(
410462 :param exclude_boundary: whether or not to exclude the date_from/to in the matched range
411463 :returns: GranueQuery instance
412464 """
413-
465+
414466 date_from , date_to = self ._format_date (date_from , date_to )
415467
416468 # good to go, make sure we have a param list
@@ -443,7 +495,7 @@ def temporal(
443495 :param exclude_boundary: whether or not to exclude the date_from/to in the matched range
444496 :returns: GranueQuery instance
445497 """
446-
498+
447499 date_from , date_to = self ._format_date (date_from , date_to )
448500
449501 # good to go, make sure we have a param list
@@ -490,7 +542,12 @@ def version(self, version: str) -> Self:
490542
491543 def point (self , lon : FloatLike , lat : FloatLike ) -> Self :
492544 """
493- Filter by granules that include a geographic point.
545+ Filter by granules that include one or more geographic points. Call this method
546+ once for each point of interest.
547+
548+ By default, query results will include items that include _all_ given points.
549+ To return items that include _any_ given point, set the option on your `query`
550+ instance like so: `query.options["point"] = {"or": True}`
494551
495552 :param lon: longitude of geographic point
496553 :param lat: latitude of geographic point
@@ -501,7 +558,10 @@ def point(self, lon: FloatLike, lat: FloatLike) -> Self:
501558 lon = float (lon )
502559 lat = float (lat )
503560
504- self .params ['point' ] = f"{ lon } ,{ lat } "
561+ if "point" not in self .params :
562+ self .params ["point" ] = []
563+
564+ self .params ["point" ].append (f"{ lon } ,{ lat } " )
505565
506566 return self
507567
@@ -657,6 +717,20 @@ def entry_title(self, entry_title: str) -> Self:
657717
658718 return self
659719
720+ def platform (self , platform : str ) -> Self :
721+ """
722+ Filter by the satellite platform the granule came from.
723+
724+ :param platform: name of the satellite
725+ :returns: self
726+ """
727+
728+ if not platform :
729+ raise ValueError ("Please provide a value for platform" )
730+
731+ self .params ['platform' ] = platform
732+ return self
733+
660734
661735class GranuleQuery (GranuleCollectionBaseQuery ):
662736 """
@@ -753,20 +827,6 @@ def instrument(self, instrument: str) -> Self:
753827 self .params ['instrument' ] = instrument
754828 return self
755829
756- def platform (self , platform : str ) -> Self :
757- """
758- Filter by the satellite platform the granule came from.
759-
760- :param platform: name of the satellite
761- :returns: self
762- """
763-
764- if not platform :
765- raise ValueError ("Please provide a value for platform" )
766-
767- self .params ['platform' ] = platform
768- return self
769-
770830 def sort_key (self , sort_key : str ) -> Self :
771831 """
772832 See
@@ -854,12 +914,34 @@ def readable_granule_name(
854914
855915 return self
856916
917+ def collection_concept_id (self , IDs : Union [str , Sequence [str ]]) -> Self :
918+ """
919+ STAC output requires collection_concept_id
920+
921+ :param IDs: concept ID(s) to search by. Can be provided as a string or list of strings.
922+ :returns: self
923+ """
924+
925+ if isinstance (IDs , str ):
926+ IDs = [IDs ]
927+
928+ # verify we weren't provided any granule concept IDs
929+ for ID in IDs :
930+ if ID .strip ()[0 ] not in self .concept_id_chars :
931+ raise ValueError (
932+ f"Only concept IDs that begin with '{ self .concept_id_chars } ' can be provided: { ID } "
933+ )
934+
935+ self .params ["collection_concept_id" ] = IDs
936+
937+ return self
938+
857939 @override
858940 def _valid_state (self ) -> bool :
859941
860942 # spatial params must be paired with a collection limiting parameter
861943 spatial_keys = ["point" , "polygon" , "bounding_box" , "line" ]
862- collection_keys = ["short_name" , "entry_title" ]
944+ collection_keys = ["short_name" , "entry_title" , "collection_concept_id" ]
863945
864946 if any (key in self .params for key in spatial_keys ):
865947 if not any (key in self .params for key in collection_keys ):
@@ -994,6 +1076,23 @@ def cloud_hosted(self, cloud_hosted: bool) -> Self:
9941076
9951077 return self
9961078
1079+ def processing_level_id (self , IDs : Union [str , Sequence [str ]]) -> Self :
1080+ """
1081+ Filter collections matching processing level ID (ex: 2)
1082+
1083+ Collections are associated with this processing level ID.
1084+
1085+ :param IDs: processing level ID(s) to search by. Can be provided as a string or list of strings.
1086+ :returns: self
1087+ """
1088+
1089+ if isinstance (IDs , str ):
1090+ IDs = [IDs ]
1091+
1092+ self .params ["processing_level_id" ] = IDs
1093+
1094+ return self
1095+
9971096 @override
9981097 def _valid_state (self ) -> bool :
9991098 return True
@@ -1110,6 +1209,22 @@ def __init__(self, mode: str = CMR_OPS):
11101209 "dif" , "dif10" , "opendata" , "umm_json" , "umm_json_v[0-9]_[0-9]"
11111210 ])
11121211
1212+ def instance_format (self , format : Union [str , Sequence [str ]]) -> Self :
1213+ """
1214+ Filter by instance format(s), matching any one of the specified formats.
1215+ Does nothing if `format` is an empty string or an empty sequence.
1216+
1217+ :param format: format(s) for variable instance (a single string, or sequence of
1218+ strings)
1219+ :returns: self
1220+ """
1221+
1222+ if format :
1223+ # Assume we have non-empty string or sequence of strings (list, tuple, etc.)
1224+ self .params ['instance_format' ] = [format ] if isinstance (format , str ) else format
1225+
1226+ return self
1227+
11131228 @override
11141229 def _valid_state (self ) -> bool :
11151230 return True
0 commit comments