Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions earthaccess/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ def search_datasets(count: int = -1, **kwargs: Any) -> List[DataCollection]:
* **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)`

Expand Down Expand Up @@ -100,6 +112,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)`

Expand Down
195 changes: 195 additions & 0 deletions earthaccess/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,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.
Expand Down Expand Up @@ -843,6 +942,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,
Expand Down
18 changes: 18 additions & 0 deletions stubs/cmr/queries.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
57 changes: 56 additions & 1 deletion tests/unit/test_granule_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Loading