Skip to content

Commit b197edd

Browse files
authored
Merge pull request #86 from nasa/release/0.13.0
Release/0.13.0
2 parents 4be001b + 747c8cc commit b197edd

13 files changed

Lines changed: 2418 additions & 351 deletions

.github/workflows/python-app.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ name: Tests
55

66
on:
77
push:
8-
branches: [ develop ]
8+
branches: [ develop, release/* ]
99
pull_request:
10-
branches: [ develop, main ]
10+
branches: [ develop, main, release/* ]
1111

1212
concurrency:
1313
group: ${{ github.workflow }}-${{ github.ref }}

CHANGELOG.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@ The format is based on
66
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
77
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9-
## [Unreleased]
9+
## [0.13.0]
10+
11+
### Added
12+
13+
- Support STAC format output ([#81](https://github.com/nasa/python_cmr/issues/81))
14+
- Add `Query` method `option` for setting parameter options as described both in
15+
[CMR Search API Parameter Options](https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#parameter-options)
16+
and in other sections of the CMR Search API documentation, thus supporting
17+
other parameter options that are not covered in that particular section of the
18+
documentation. ([#74](https://github.com/nasa/python_cmr/issues/74))
19+
- Support multi-point searches ([#72](https://github.com/nasa/python_cmr/issues/72))
20+
- Support `processing_level_id` in `CollectionQuery` ([#76](https://github.com/nasa/python_cmr/issues/76))
21+
- Support `platform` in `CollectionQuery` ([#77](https://github.com/nasa/python_cmr/issues/77))
22+
- Support searching by instance format for `VariableQuery` ([#59](https://github.com/nasa/python_cmr/issues/59))
23+
24+
### Fixed
25+
- Setup vcrpy for new `revision_date` unit tests ([#70](https://github.com/nasa/python_cmr/issues/70))
26+
1027

1128
## [0.12.0]
1229

@@ -132,7 +149,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
132149
- Prior releases of this software originated from
133150
<https://github.com/jddeal/python-cmr/releases>
134151

135-
[Unreleased]: https://github.com/nasa/python_cmr/compare/v0.12.0...HEAD
152+
[Unreleased]: https://github.com/nasa/python_cmr/compare/v0.13.0...HEAD
153+
[0.13.0]: https://github.com/nasa/python_cmr/compare/v0.12.0...v0.13.0
136154
[0.12.0]: https://github.com/nasa/python_cmr/compare/v0.11.0...v0.12.0
137155
[0.11.0]: https://github.com/nasa/python_cmr/compare/v0.10.0...v0.11.0
138156
[0.10.0]: https://github.com/nasa/python_cmr/compare/v0.9.0...v0.10.0

MANIFEST.in

Lines changed: 0 additions & 2 deletions
This file was deleted.

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ api.downloadable()
111111
# only include granules that are unavailable for download
112112
api.online_only()
113113

114+
# filter by specific satellite platform
115+
api.platform("Terra")
116+
114117
# search for collections/granules associated with or identified by concept IDs
115118
# note: often the ECHO collection ID can be used here as well
116119
# note: when using CollectionQuery, only collection concept IDs can be passed
@@ -143,9 +146,8 @@ api.day_night_flag("day")
143146
# filter by cloud cover percentage range
144147
api.cloud_cover(25, 75)
145148

146-
# filter by specific instrument or platform
149+
# filter by specific instrument
147150
api.instrument("MODIS")
148-
api.platform("Terra")
149151

150152
# filter by a sort_key note: sort_keys are require some other fields to find
151153
# some existing granules before they can be sorted
@@ -169,6 +171,9 @@ api.tool_concept_id('TL2092786348-POCLOUD')
169171

170172
# filter by service concept id
171173
api.service_concept_id('S1962070864-POCLOUD')
174+
175+
# filter by processing level id
176+
api.processing_level_id('3')
172177
```
173178

174179
Service searches support the following methods
@@ -220,6 +225,9 @@ api.name('/AMR_Side_1/acc_lat')
220225

221226
# Search via concept_id
222227
api.concept_id('V2112019824-POCLOUD')
228+
229+
# Search via instance format
230+
api.instance_format(["zarr", "kerchunk"])
223231
```
224232

225233
As an alternative to chaining methods together to set the parameters of your query, a method exists to allow you to pass

cmr/queries.py

Lines changed: 146 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
from abc import abstractmethod
6+
from collections import defaultdict
67
from datetime import date, datetime, timezone
78
from inspect import getmembers, ismethod
89
from 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

316368
class 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

661735
class 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

Comments
 (0)