diff --git a/cmr/queries.py b/cmr/queries.py index cdf9f2e..3698f10 100644 --- a/cmr/queries.py +++ b/cmr/queries.py @@ -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 @@ -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. @@ -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-`` 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: ``/python_cmr-``. + :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: @@ -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 @@ -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 @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/tests/test_collection.py b/tests/test_collection.py index 2c2d866..b26fa93 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -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() diff --git a/tests/test_granule.py b/tests/test_granule.py index fbf188f..6c92e52 100644 --- a/tests/test_granule.py +++ b/tests/test_granule.py @@ -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() diff --git a/tests/test_queries.py b/tests/test_queries.py index 142a026..30a25bd 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -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