Skip to content

Commit

Permalink
Merge pull request #86 from nasa/release/0.13.0
Browse files Browse the repository at this point in the history
Release/0.13.0
  • Loading branch information
frankinspace authored Sep 13, 2024
2 parents 4be001b + 747c8cc commit b197edd
Show file tree
Hide file tree
Showing 13 changed files with 2,418 additions and 351 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ name: Tests

on:
push:
branches: [ develop ]
branches: [ develop, release/* ]
pull_request:
branches: [ develop, main ]
branches: [ develop, main, release/* ]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,24 @@ The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.13.0]

### Added

- Support STAC format output ([#81](https://github.com/nasa/python_cmr/issues/81))
- Add `Query` method `option` for setting parameter options as described both in
[CMR Search API Parameter Options](https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#parameter-options)
and in other sections of the CMR Search API documentation, thus supporting
other parameter options that are not covered in that particular section of the
documentation. ([#74](https://github.com/nasa/python_cmr/issues/74))
- Support multi-point searches ([#72](https://github.com/nasa/python_cmr/issues/72))
- Support `processing_level_id` in `CollectionQuery` ([#76](https://github.com/nasa/python_cmr/issues/76))
- Support `platform` in `CollectionQuery` ([#77](https://github.com/nasa/python_cmr/issues/77))
- Support searching by instance format for `VariableQuery` ([#59](https://github.com/nasa/python_cmr/issues/59))

### Fixed
- Setup vcrpy for new `revision_date` unit tests ([#70](https://github.com/nasa/python_cmr/issues/70))


## [0.12.0]

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

[Unreleased]: https://github.com/nasa/python_cmr/compare/v0.12.0...HEAD
[Unreleased]: https://github.com/nasa/python_cmr/compare/v0.13.0...HEAD
[0.13.0]: https://github.com/nasa/python_cmr/compare/v0.12.0...v0.13.0
[0.12.0]: https://github.com/nasa/python_cmr/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/nasa/python_cmr/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/nasa/python_cmr/compare/v0.9.0...v0.10.0
Expand Down
2 changes: 0 additions & 2 deletions MANIFEST.in

This file was deleted.

12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ api.downloadable()
# only include granules that are unavailable for download
api.online_only()

# filter by specific satellite platform
api.platform("Terra")

# 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
Expand Down Expand Up @@ -143,9 +146,8 @@ api.day_night_flag("day")
# filter by cloud cover percentage range
api.cloud_cover(25, 75)

# filter by specific instrument or platform
# filter by specific instrument
api.instrument("MODIS")
api.platform("Terra")

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

# filter by service concept id
api.service_concept_id('S1962070864-POCLOUD')

# filter by processing level id
api.processing_level_id('3')
```

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

# Search via concept_id
api.concept_id('V2112019824-POCLOUD')

# Search via instance format
api.instance_format(["zarr", "kerchunk"])
```

As an alternative to chaining methods together to set the parameters of your query, a method exists to allow you to pass
Expand Down
177 changes: 146 additions & 31 deletions cmr/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from abc import abstractmethod
from collections import defaultdict
from datetime import date, datetime, timezone
from inspect import getmembers, ismethod
from re import search
Expand Down Expand Up @@ -46,12 +47,12 @@ class Query:
_format = "json"
_valid_formats_regex = [
"json", "xml", "echo10", "iso", "iso19115",
"csv", "atom", "kml", "native"
"csv", "atom", "kml", "native", "stac",
]

def __init__(self, route: str, mode: str = CMR_OPS):
self.params: MutableMapping[str, Any] = {}
self.options: MutableMapping[str, Any] = {}
self.options: MutableMapping[str, MutableMapping[str, Any]] = defaultdict(dict)
self._route = route
self.mode(mode)
self.concept_id_chars: Set[str] = set()
Expand Down Expand Up @@ -190,8 +191,10 @@ def _build_url(self) -> str:
if isinstance(val, list):
for list_val in val:
formatted_params.append(f"{key}[]={list_val}")

elif isinstance(val, bool):
formatted_params.append(f"{key}={str(val).lower()}")

else:
formatted_params.append(f"{key}={val}")

Expand All @@ -201,13 +204,6 @@ def _build_url(self) -> str:
formatted_options: List[str] = []
for param_key in self.options:
for option_key, val in self.options[param_key].items():

# all CMR options must be booleans
if not isinstance(val, bool):
raise TypeError(
f"parameter '{param_key}' with option '{option_key}' must be a boolean"
)

formatted_options.append(f"options[{param_key}][{option_key}]={str(val).lower()}")

options_as_string = "&".join(formatted_options)
Expand Down Expand Up @@ -312,6 +308,62 @@ def bearer_token(self, bearer_token: str) -> Self:

return self

def option(
self, parameter: str, key: str, value: Union[str, bool, int, float, None]
) -> Self:
"""
Set or remove a search parameter option.
If either an empty parameter name or option key is given, do nothing.
Otherwise, if a non-`None` option value is given, set the specified parameter
option to the value; else _remove_ the parameter option, if previously given.
In all cases, return self to support method chaining.
See `CMR Search API Parameter Options`_ as well as other sections of the
documentation that describe other available parameter options.
.. _CMR Search API Parameter Options:
https://cmr.earthdata.nasa.gov/search/site/docs/search/api.html#parameter-options
Example:
.. code:: python
>>> query = CollectionQuery()
>>> query.option("short_name", "ignore_case", True) # doctest: +ELLIPSIS
<cmr.queries.CollectionQuery ...>
>>> query.options # doctest: +ELLIPSIS
defaultdict(..., {'short_name': {'ignore_case': True}})
>>> query.option("short_name", "ignore_case", False) # doctest: +ELLIPSIS
<cmr.queries.CollectionQuery ...>
>>> query.options # doctest: +ELLIPSIS
defaultdict(..., {'short_name': {'ignore_case': False}})
>>> (query
... .option("short_name", "ignore_case", None) # remove an option
... .option("short_name", "or", True)
... .option("highlights", "begin_tag", "<b>")
... .option("highlights", "end_tag", "</b>")
... )
<cmr.queries.CollectionQuery ...>
>>> query.options # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
defaultdict(..., {'short_name': {'or': True},
'highlights': {'begin_tag': '<b>', 'end_tag': '</b>'}})
:param parameter: search parameter to set an option for
:param key: option key to set a value for
:param value: value to set for the option, or `None` to remove the option
:returns: self
"""

if parameter and key:
if value is None:
del self.options[parameter][key]
else:
self.options[parameter][key] = value

return self


class GranuleCollectionBaseQuery(Query):
"""
Expand Down Expand Up @@ -345,12 +397,12 @@ def _format_date(
) -> Tuple[str, str]:
"""
Format dates into expected format for date queries.
:param date_from: earliest date of temporal range
:param date_to: latest date of temporal range
:returns: Tuple instance
"""

iso_8601 = "%Y-%m-%dT%H:%M:%SZ"

# process each date into a datetime object
Expand Down Expand Up @@ -390,7 +442,7 @@ def convert_to_string(date: Optional[DateLike], default: datetime) -> str:
# if we have both dates, make sure from isn't later than to
if date_from and date_to and date_from > date_to:
raise ValueError("date_from must be earlier than date_to.")

return date_from, date_to

def revision_date(
Expand All @@ -410,7 +462,7 @@ def revision_date(
:param exclude_boundary: whether or not to exclude the date_from/to in the matched range
:returns: GranueQuery instance
"""

date_from, date_to = self._format_date(date_from, date_to)

# good to go, make sure we have a param list
Expand Down Expand Up @@ -443,7 +495,7 @@ def temporal(
:param exclude_boundary: whether or not to exclude the date_from/to in the matched range
:returns: GranueQuery instance
"""

date_from, date_to = self._format_date(date_from, date_to)

# good to go, make sure we have a param list
Expand Down Expand Up @@ -490,7 +542,12 @@ def version(self, version: str) -> Self:

def point(self, lon: FloatLike, lat: FloatLike) -> Self:
"""
Filter by granules that include a geographic point.
Filter by granules that include one or more geographic points. Call this method
once for each point of interest.
By default, query results will include items that include _all_ given points.
To return items that include _any_ given point, set the option on your `query`
instance like so: `query.options["point"] = {"or": True}`
:param lon: longitude of geographic point
:param lat: latitude of geographic point
Expand All @@ -501,7 +558,10 @@ def point(self, lon: FloatLike, lat: FloatLike) -> Self:
lon = float(lon)
lat = float(lat)

self.params['point'] = f"{lon},{lat}"
if "point" not in self.params:
self.params["point"] = []

self.params["point"].append(f"{lon},{lat}")

return self

Expand Down Expand Up @@ -657,6 +717,20 @@ def entry_title(self, entry_title: str) -> Self:

return self

def platform(self, platform: str) -> Self:
"""
Filter by the satellite platform the granule came from.
:param platform: name of the satellite
:returns: self
"""

if not platform:
raise ValueError("Please provide a value for platform")

self.params['platform'] = platform
return self


class GranuleQuery(GranuleCollectionBaseQuery):
"""
Expand Down Expand Up @@ -753,20 +827,6 @@ def instrument(self, instrument: str) -> Self:
self.params['instrument'] = instrument
return self

def platform(self, platform: str) -> Self:
"""
Filter by the satellite platform the granule came from.
:param platform: name of the satellite
:returns: self
"""

if not platform:
raise ValueError("Please provide a value for platform")

self.params['platform'] = platform
return self

def sort_key(self, sort_key: str) -> Self:
"""
See
Expand Down Expand Up @@ -854,12 +914,34 @@ def readable_granule_name(

return self

def collection_concept_id(self, IDs: Union[str, Sequence[str]]) -> Self:
"""
STAC output requires collection_concept_id
:param IDs: concept ID(s) to search by. Can be provided as a string or list of strings.
:returns: self
"""

if isinstance(IDs, str):
IDs = [IDs]

# verify we weren't provided any granule concept IDs
for ID in IDs:
if ID.strip()[0] not in self.concept_id_chars:
raise ValueError(
f"Only concept IDs that begin with '{self.concept_id_chars}' can be provided: {ID}"
)

self.params["collection_concept_id"] = IDs

return self

@override
def _valid_state(self) -> bool:

# spatial params must be paired with a collection limiting parameter
spatial_keys = ["point", "polygon", "bounding_box", "line"]
collection_keys = ["short_name", "entry_title"]
collection_keys = ["short_name", "entry_title", "collection_concept_id"]

if any(key in self.params for key in spatial_keys):
if not any(key in self.params for key in collection_keys):
Expand Down Expand Up @@ -994,6 +1076,23 @@ def cloud_hosted(self, cloud_hosted: bool) -> Self:

return self

def processing_level_id(self, IDs: Union[str, Sequence[str]]) -> Self:
"""
Filter collections matching processing level ID (ex: 2)
Collections are associated with this processing level ID.
:param IDs: processing level ID(s) to search by. Can be provided as a string or list of strings.
:returns: self
"""

if isinstance(IDs, str):
IDs = [IDs]

self.params["processing_level_id"] = IDs

return self

@override
def _valid_state(self) -> bool:
return True
Expand Down Expand Up @@ -1110,6 +1209,22 @@ def __init__(self, mode: str = CMR_OPS):
"dif", "dif10", "opendata", "umm_json", "umm_json_v[0-9]_[0-9]"
])

def instance_format(self, format: Union[str, Sequence[str]]) -> Self:
"""
Filter by instance format(s), matching any one of the specified formats.
Does nothing if `format` is an empty string or an empty sequence.
:param format: format(s) for variable instance (a single string, or sequence of
strings)
:returns: self
"""

if format:
# Assume we have non-empty string or sequence of strings (list, tuple, etc.)
self.params['instance_format'] = [format] if isinstance(format, str) else format

return self

@override
def _valid_state(self) -> bool:
return True
Loading

0 comments on commit b197edd

Please sign in to comment.