Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
66 changes: 58 additions & 8 deletions cmr/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from abc import abstractmethod
from collections import defaultdict
from datetime import date, datetime, timezone
from importlib.metadata import version as _pkg_version
from inspect import getmembers, ismethod
from re import search
from typing import Iterator
Expand Down Expand Up @@ -39,6 +40,19 @@
PointLike: TypeAlias = Tuple[FloatLike, FloatLike]


def _format_float(value: float) -> str:
"""Format a float as a plain decimal string, never using scientific notation.

Python's default str() and f-strings switch to scientific notation for numbers
outside roughly [1e-4, 1e16), e.g. ``1e-05`` for ``0.00001``. CMR rejects
scientific notation in URL parameters with "is not a valid URL encoded point".
"""
formatted = f"{value:.15g}"
if "e" in formatted or "E" in formatted:
formatted = f"{value:.15f}".rstrip("0").rstrip(".")
return formatted


class Query:
"""
Base class for all CMR queries.
Expand Down Expand Up @@ -365,6 +379,34 @@ def bearer_token(self, bearer_token: str) -> Self:

return self

def client_id(self, client_id: Optional[str] = None) -> Self:
"""
Set the ``Client-Id`` header to identify this client to the CMR API.

If no *client_id* is provided (or ``None`` is passed), a default value
of ``python_cmr-<version>`` is used so that NASA Operations can track
queries by library version.

:param client_id: an optional human-readable identifier for your
application. When supplied, the string is suffixed with the
library version: ``<client_id>/python_cmr-<version>``.
:returns: self
"""

try:
lib_version = _pkg_version("python-cmr")
except Exception:
lib_version = "unknown"

if client_id:
header_value = f"{client_id}/python_cmr-{lib_version}"
else:
header_value = f"python_cmr-{lib_version}"

self.headers.update({"Client-Id": header_value})

return self

def option(
self, parameter: str, key: str, value: Union[str, bool, int, float, None]
) -> Self:
Expand Down Expand Up @@ -618,7 +660,7 @@ def point(self, lon: FloatLike, lat: FloatLike) -> Self:
if "point" not in self.params:
self.params["point"] = []

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

return self

Expand All @@ -630,7 +672,7 @@ def circle(self, lon: FloatLike, lat: FloatLike, dist: FloatLike) -> Self:
:param dist: distance in meters around waypoint (lat,lon)
:returns: self
"""
self.params['circle'] = f"{lon},{lat},{dist}"
self.params['circle'] = f"{_format_float(lon)},{_format_float(lat)},{_format_float(float(dist))}"

return self

Expand Down Expand Up @@ -672,7 +714,7 @@ def polygon(self, coordinates: Sequence[PointLike]) -> Self:
)

# convert to strings
as_strs = [str(val) for val in as_floats]
as_strs = [_format_float(val) for val in as_floats]

self.params["polygon"] = ",".join(as_strs)

Expand All @@ -697,7 +739,8 @@ def bounding_box(
"""

self.params["bounding_box"] = (
f"{float(lower_left_lon)},{float(lower_left_lat)},{float(upper_right_lon)},{float(upper_right_lat)}"
f"{_format_float(float(lower_left_lon))},{_format_float(float(lower_left_lat))},"
f"{_format_float(float(upper_right_lon))},{_format_float(float(upper_right_lat))}"
)

return self
Expand Down Expand Up @@ -734,7 +777,7 @@ def line(self, coordinates: Sequence[PointLike]) -> Self:
as_floats.extend([float(lon), float(lat)])

# cast back to string for join
as_strs = [str(val) for val in as_floats]
as_strs = [_format_float(val) for val in as_floats]

self.params["line"] = ",".join(as_strs)

Expand Down Expand Up @@ -774,18 +817,25 @@ def entry_title(self, entry_title: str) -> Self:

return self

def platform(self, platform: str) -> Self:
def platform(self, platform: Union[str, Sequence[str]]) -> Self:
"""
Filter by the satellite platform the granule came from.

:param platform: name of the satellite
Accepts either a single platform name or a list of platform names.
When multiple platforms are provided, CMR returns results matching
any of them.

:param platform: name of the satellite, or a list of satellite names
:returns: self
"""

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

self.params['platform'] = platform
if isinstance(platform, str):
platform = [platform]

self.params['platform'] = list(platform)
return self


Expand Down
10 changes: 9 additions & 1 deletion tests/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,15 @@ def test_platform(self):
query.platform("1B")

self.assertIn("platform", query.params)
self.assertEqual(query.params["platform"], "1B")
self.assertEqual(query.params["platform"], ["1B"])

def test_platform_multiple(self):
query = CollectionQuery()

query.platform(["Terra", "Aqua"])

self.assertIn("platform", query.params)
self.assertEqual(query.params["platform"], ["Terra", "Aqua"])

def test_empty_platform(self):
query = CollectionQuery()
Expand Down
10 changes: 9 additions & 1 deletion tests/test_granule.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,15 @@ def test_platform(self):
query.platform("1B")

self.assertIn(self.platform, query.params)
self.assertEqual(query.params[self.platform], "1B")
self.assertEqual(query.params[self.platform], ["1B"])

def test_platform_multiple(self):
query = GranuleQuery()

query.platform(["Terra", "Aqua"])

self.assertIn(self.platform, query.params)
self.assertEqual(query.params[self.platform], ["Terra", "Aqua"])

def test_sort_key(self):
query = GranuleQuery()
Expand Down
25 changes: 25 additions & 0 deletions tests/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,28 @@ def test_token_replaces_existing_auth_header():
query.token("token")

assert query.headers["Authorization"] == "token"


def test_client_id_default_uses_library_version():
query = MockQuery("/foo")
query.client_id()

assert "Client-Id" in query.headers
assert query.headers["Client-Id"].startswith("python_cmr-")


def test_client_id_custom_includes_app_name_and_version():
query = MockQuery("/foo")
query.client_id("my-app")

assert "Client-Id" in query.headers
assert query.headers["Client-Id"].startswith("my-app/python_cmr-")


def test_client_id_does_not_clobber_other_headers():
query = MockQuery("/foo")
query.headers["Authorization"] = "Bearer token"
query.client_id("my-app")

assert query.headers["Authorization"] == "Bearer token"
assert "Client-Id" in query.headers