diff --git a/CHANGELOG.md b/CHANGELOG.md index 173f773f..0d6442ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - `get_s3_filesystem` now accepts an `endpoint` argument for specifying a credentials url. ([#602](https://github.com/nsidc/earthaccess/issues/602)) ([@rwegener2](https://github.com/rwegener2)) +- `search_data` and `search_datasets` now accept query parameters `revision_date`, `created_at`, `production_date`, `point`, `line`, `polygon`. +([#1007](https://github.com/nsidc/earthaccess/issues/1007)) +([@ninsbl](https://github.com/ninsbl)) + ### Changed diff --git a/earthaccess/api.py b/earthaccess/api.py index 2a4c66ac..d56b070c 100644 --- a/earthaccess/api.py +++ b/earthaccess/api.py @@ -61,6 +61,18 @@ def search_datasets(count: int = -1, **kwargs: Any) -> List[DataCollection]: * **has_granules**: if true, only return collections with granules * **temporal**: a tuple representing temporal bounds in the form `(date_from, date_to)` + * **revision_date**: a tuple representing revision date bounds in the form + `(date_from, date_to)` + * **created_at**: a tuple representing creation time bounds in the form + `(date_from, date_to)` + * **production_date**: a tuple representing production date bounds in the form + `(date_from, date_to)` + * **point**: a tuple representing longitude and latitude of a geographic point + in the form of `(lon, lat)` + * **line**: a list of geographic point coordinates longitude and latitude + describing a line in the form `[lon, lat, lon, lat, ...]` + * **polygon**: a list of geographic point coordinates longitude and latitude + describing a polygon in the form `[lon, lat, lon, lat, ...]` * **bounding_box**: a tuple representing spatial bounds in the form `(lower_left_lon, lower_left_lat, upper_right_lon, upper_right_lat)` @@ -112,6 +124,18 @@ def search_data(count: int = -1, **kwargs: Any) -> List[DataGranule]: * **provider**: particular to each DAAC, e.g. POCLOUD, LPDAAC etc. * **temporal**: a tuple representing temporal bounds in the form `(date_from, date_to)` + * **revision_date**: a tuple representing revision date bounds in the form + `(date_from, date_to)` + * **created_at**: a tuple representing creation time bounds in the form + `(date_from, date_to)` + * **production_date**: a tuple representing production date bounds in the form + `(date_from, date_to)` + * **point**: a tuple representing longitude and latitude of a geographic point + in the form of `(lon, lat)` + * **line**: a list of geographic point coordinates longitude and latitude + describing a line in the form `[lon, lat, lon, lat, ...]` + * **polygon**: a list of geographic point coordinates longitude and latitude + describing a polygon in the form `[lon, lat, lon, lat, ...]` * **bounding_box**: a tuple representing spatial bounds in the form `(lower_left_lon, lower_left_lat, upper_right_lon, upper_right_lat)` diff --git a/earthaccess/search.py b/earthaccess/search.py index ef9ea83f..cebb466e 100644 --- a/earthaccess/search.py +++ b/earthaccess/search.py @@ -418,6 +418,105 @@ def temporal( """ return super().temporal(date_from, date_to, exclude_boundary) + @override + def revision_date( + self, + date_from: Optional[Union[str, dt.date, dt.datetime]] = None, + date_to: Optional[Union[str, dt.date, dt.datetime]] = None, + exclude_boundary: bool = False, + ) -> Self: + """Filter by an open or closed date range. Dates can be provided as date objects + or ISO 8601 strings. Multiple ranges can be provided by successive method calls. + + ???+ Tip + Giving either `datetime.date(YYYY, MM, DD)` or `"YYYY-MM-DD"` as the `date_to` + parameter includes that entire day (i.e. the time is set to `23:59:59`). + Using `datetime.datetime(YYYY, MM, DD)` is different, because `datetime.datetime` + objects have `00:00:00` as their built-in default. + + Parameters: + date_from: start of revision date range + date_to: end of revision date range + exclude_boundary: whether or not to exclude the date_from/to in + the matched range. + + Returns: + self + + Raises: + ValueError: `date_from` or `date_to` is a non-`None` value that is + neither a datetime object nor a string that can be parsed as a datetime + object; or `date_from` and `date_to` are both datetime objects (or + parsable as such) and `date_from` is after `date_to`. + """ + return super().revision_date(date_from, date_to, exclude_boundary) + + @override + def created_at( + self, + date_from: Optional[Union[str, dt.date, dt.datetime]] = None, + date_to: Optional[Union[str, dt.date, dt.datetime]] = None, + exclude_boundary: bool = False, + ) -> Self: + """Filter by an open or closed date range. Dates can be provided as date objects + or ISO 8601 strings. Multiple ranges can be provided by successive method calls. + + ???+ Tip + Giving either `datetime.date(YYYY, MM, DD)` or `"YYYY-MM-DD"` as the `date_to` + parameter includes that entire day (i.e. the time is set to `23:59:59`). + Using `datetime.datetime(YYYY, MM, DD)` is different, because `datetime.datetime` + objects have `00:00:00` as their built-in default. + + Parameters: + date_from: start of creation time range + date_to: end of creation time range + exclude_boundary: whether or not to exclude the date_from/to in + the matched range. + + Returns: + self + + Raises: + ValueError: `date_from` or `date_to` is a non-`None` value that is + neither a datetime object nor a string that can be parsed as a datetime + object; or `date_from` and `date_to` are both datetime objects (or + parsable as such) and `date_from` is after `date_to`. + """ + return super().created_at(date_from, date_to, exclude_boundary) + + @override + def production_date( + self, + date_from: Optional[Union[str, dt.date, dt.datetime]] = None, + date_to: Optional[Union[str, dt.date, dt.datetime]] = None, + exclude_boundary: bool = False, + ) -> Self: + """Filter by an open or closed date range. Dates can be provided as date objects + or ISO 8601 strings. Multiple ranges can be provided by successive method calls. + + ???+ Tip + Giving either `datetime.date(YYYY, MM, DD)` or `"YYYY-MM-DD"` as the `date_to` + parameter includes that entire day (i.e. the time is set to `23:59:59`). + Using `datetime.datetime(YYYY, MM, DD)` is different, because `datetime.datetime` + objects have `00:00:00` as their built-in default. + + Parameters: + date_from: start of production date range + date_to: end of production date range + exclude_boundary: whether or not to exclude the date_from/to in + the matched range. + + Returns: + self + + Raises: + ValueError: `date_from` or `date_to` is a non-`None` value that is + neither a datetime object nor a string that can be parsed as a datetime + object; or `date_from` and `date_to` are both datetime objects (or + parsable as such) and `date_from` is after `date_to`. + """ + return super().production_date(date_from, date_to, exclude_boundary) + class DataGranules(GranuleQuery): """A Granule oriented client for NASA CMR. @@ -817,6 +916,102 @@ def temporal( """ return super().temporal(date_from, date_to, exclude_boundary) + @override + def revision_date( + self, + date_from: Optional[Union[str, dt.date, dt.datetime]] = None, + date_to: Optional[Union[str, dt.date, dt.datetime]] = None, + exclude_boundary: bool = False, + ) -> Self: + """Filter by an open or closed date range. Dates can be provided as date objects + or ISO 8601 strings. Multiple ranges can be provided by successive method calls. + + ???+ Tip + Giving either `datetime.date(YYYY, MM, DD)` or `"YYYY-MM-DD"` as the `date_to` + parameter includes that entire day (i.e. the time is set to `23:59:59`). + Using `datetime.datetime(YYYY, MM, DD)` is different, because `datetime.datetime` + objects have `00:00:00` as their built-in default. + + Parameters: + date_from: earliest revision date to return + date_to: latest revision date to return + exclude_boundary: whether to exclude the date_from and date_to in the matched range + + Returns: + self + + Raises: + ValueError: `date_from` or `date_to` is a non-`None` value that is + neither a datetime object nor a string that can be parsed as a datetime + object; or `date_from` and `date_to` are both datetime objects (or + parsable as such) and `date_from` is after `date_to`. + """ + return super().revision_date(date_from, date_to, exclude_boundary) + + @override + def created_at( + self, + date_from: Optional[Union[str, dt.date, dt.datetime]] = None, + date_to: Optional[Union[str, dt.date, dt.datetime]] = None, + exclude_boundary: bool = False, + ) -> Self: + """Filter by an open or closed date range. Dates can be provided as date objects + or ISO 8601 strings. Multiple ranges can be provided by successive method calls. + + ???+ Tip + Giving either `datetime.date(YYYY, MM, DD)` or `"YYYY-MM-DD"` as the `date_to` + parameter includes that entire day (i.e. the time is set to `23:59:59`). + Using `datetime.datetime(YYYY, MM, DD)` is different, because `datetime.datetime` + objects have `00:00:00` as their built-in default. + + Parameters: + date_from: earliest creation time to return + date_to: latest creation time to return + exclude_boundary: whether to exclude the date_from and date_to in the matched range + + Returns: + self + + Raises: + ValueError: `date_from` or `date_to` is a non-`None` value that is + neither a datetime object nor a string that can be parsed as a datetime + object; or `date_from` and `date_to` are both datetime objects (or + parsable as such) and `date_from` is after `date_to`. + """ + return super().created_at(date_from, date_to, exclude_boundary) + + @override + def production_date( + self, + date_from: Optional[Union[str, dt.date, dt.datetime]] = None, + date_to: Optional[Union[str, dt.date, dt.datetime]] = None, + exclude_boundary: bool = False, + ) -> Self: + """Filter by an open or closed date range. Dates can be provided as date objects + or ISO 8601 strings. Multiple ranges can be provided by successive method calls. + + ???+ Tip + Giving either `datetime.date(YYYY, MM, DD)` or `"YYYY-MM-DD"` as the `date_to` + parameter includes that entire day (i.e. the time is set to `23:59:59`). + Using `datetime.datetime(YYYY, MM, DD)` is different, because `datetime.datetime` + objects have `00:00:00` as their built-in default. + + Parameters: + date_from: earliest production date to return + date_to: latest production date to return + exclude_boundary: whether to exclude the date_from and date_to in the matched range + + Returns: + self + + Raises: + ValueError: `date_from` or `date_to` is a non-`None` value that is + neither a datetime object nor a string that can be parsed as a datetime + object; or `date_from` and `date_to` are both datetime objects (or + parsable as such) and `date_from` is after `date_to`. + """ + return super().production_date(date_from, date_to, exclude_boundary) + @override def version(self, version: str) -> Self: """Filter by version. Note that CMR defines this as a string. For example, diff --git a/stubs/cmr/queries.pyi b/stubs/cmr/queries.pyi index b7aeb20c..56b5e2d0 100644 --- a/stubs/cmr/queries.pyi +++ b/stubs/cmr/queries.pyi @@ -52,6 +52,24 @@ class GranuleCollectionBaseQuery(Query): date_to: Optional[Union[str, date, datetime]], exclude_boundary: bool = False, ) -> Self: ... + def revision_date( + self, + date_from: Optional[Union[str, date, datetime]], + date_to: Optional[Union[str, date, datetime]], + exclude_boundary: bool = False, + ) -> Self: ... + def created_at( + self, + date_from: Optional[Union[str, date, datetime]], + date_to: Optional[Union[str, date, datetime]], + exclude_boundary: bool = False, + ) -> Self: ... + def production_date( + self, + date_from: Optional[Union[str, date, datetime]], + date_to: Optional[Union[str, date, datetime]], + exclude_boundary: bool = False, + ) -> Self: ... def short_name(self, short_name: str) -> Self: ... def version(self, version: str) -> Self: ... def point(self, lon: FloatLike, lat: FloatLike) -> Self: ... diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index f6ec1fcd..f33e03c0 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -23,6 +23,24 @@ # Chiapas, Mexico "bounding_box": (-92.86, 16.26, -91.58, 16.97), }, + { + "data_center": "NSIDC", + "short_name": "ATL08", + "revision_date": "2021-02-01T00:00:00Z,2021-02-02T00:00:00Z", + # Chiapas, Mexico + "polygon": ( + -92.86, + 16.26, + -91.58, + 16.26, + -91.58, + 16.97, + -92.86, + 16.97, + -92.86, + 16.26, + ), + }, { "concept_id": "C2021957295-LPCLOUD", "day_night_flag": "day", diff --git a/tests/unit/test_granule_queries.py b/tests/unit/test_granule_queries.py index 678e77be..d83a1a54 100644 --- a/tests/unit/test_granule_queries.py +++ b/tests/unit/test_granule_queries.py @@ -31,18 +31,40 @@ ("2999-02-01", "2009-01-01", None), ] - bbox_queries = [ ([-134.7, 54.9, -100.9, 69.2], True), ([-10, 20, 0, 40], True), ([10, 20, 30, 40], True), ] +point_queries = [ + ([-134.7, 54.9], True), + ([-10, 20], True), + ([30, 40], True), +] + +line_queries = [ + ([-134.7, 54.9, -100.9, 69.2], True), + ([-10, 20, 0, 40], True), + ([10, 20, 30, 40], True), +] + +polygon_queries = [ + ([-134.7, 54.9, -100.9, 54.9, -100.9, 69.2, -134.7, 69.2, -134.7, 54.9], True), + ([-10, 20, 0, 20, 0, 40, -10, 40, -10, 20], True), +] + @pytest.mark.parametrize("start,end,expected", valid_single_dates) def test_query_can_parse_single_dates(start, end, expected): granules = DataGranules().short_name("MODIS").temporal(start, end) assert granules.params["temporal"][0] == expected + granules = DataGranules().short_name("MODIS").revision_date(start, end) + assert granules.params["revision_date"][0] == expected + granules = DataGranules().short_name("MODIS").created_at(start, end) + assert granules.params["created_at"][0] == expected + granules = DataGranules().short_name("MODIS").production_date(start, end) + assert granules.params["production_date"][0] == expected @pytest.mark.parametrize("start,end,expected", invalid_single_dates) @@ -53,9 +75,42 @@ def test_query_can_handle_invalid_dates(start, end, expected): except Exception as e: assert isinstance(e, ValueError) assert "temporal" not in granules.params + try: + granules = granules.revision_date(start, end) + except Exception as e: + assert isinstance(e, ValueError) + assert "revision_date" not in granules.params + try: + granules = granules.created_at(start, end) + except Exception as e: + assert isinstance(e, ValueError) + assert "created_at" not in granules.params + try: + granules = granules.production_date(start, end) + except Exception as e: + assert isinstance(e, ValueError) + assert "production_date" not in granules.params @pytest.mark.parametrize("bbox,expected", bbox_queries) def test_query_handles_bbox(bbox, expected): granules = DataGranules().short_name("MODIS").bounding_box(*bbox) assert ("bounding_box" in granules.params) == expected + + +@pytest.mark.parametrize("point,expected", point_queries) +def test_query_handles_point(point, expected): + granules = DataGranules().short_name("MODIS").point(point) + assert ("point" in granules.params) == expected + + +@pytest.mark.parametrize("line,expected", line_queries) +def test_query_handles_line(line, expected): + granules = DataGranules().short_name("MODIS").line(line) + assert ("line" in granules.params) == expected + + +@pytest.mark.parametrize("polygon,expected", polygon_queries) +def test_query_handles_polygon(polygon, expected): + granules = DataGranules().short_name("MODIS").polygon(polygon) + assert ("polygon" in granules.params) == expected