diff --git a/cmr/queries.py b/cmr/queries.py index cdf9f2e..3e1c33a 100644 --- a/cmr/queries.py +++ b/cmr/queries.py @@ -39,6 +39,25 @@ PointLike: TypeAlias = Tuple[FloatLike, FloatLike] +def _format_float(value: Union[float, int, str]) -> str: + """Format a number as a plain decimal string, never using scientific notation. + + Python's default str() switches to scientific notation for floats 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". + + Integers and strings are returned as-is (``str(1000)`` → ``"1000"``). + Floats use their natural str() representation (``str(1.0)`` → ``"1.0"``) + unless that produces scientific notation, in which case a fixed-point + decimal is returned instead. + """ + s = str(value) + if "e" not in s and "E" not in s: + return s + # Scientific notation detected: convert to plain decimal, strip trailing zeros + return f"{float(value):.15f}".rstrip("0").rstrip(".") + + class Query: """ Base class for all CMR queries. @@ -618,7 +637,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 +649,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(dist)}" return self @@ -672,7 +691,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 +716,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 +754,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) diff --git a/tests/test_granule.py b/tests/test_granule.py index fbf188f..1f702a5 100644 --- a/tests/test_granule.py +++ b/tests/test_granule.py @@ -427,6 +427,34 @@ def test_line_set(self): query.line([("1", 1.1), (2, 2)]) self.assertEqual(query.params["line"], "1.0,1.1,2.0,2.0") + def test_point_no_scientific_notation(self): + query = GranuleQuery() + query.point(1e-5, 1e-8) + self.assertEqual(query.params["point"], ["0.00001,0.00000001"]) + + def test_circle_no_scientific_notation(self): + query = GranuleQuery() + query.circle(1e-5, 1e-8, 1000) + self.assertEqual(query.params["circle"], "0.00001,0.00000001,1000") + + def test_bounding_box_no_scientific_notation(self): + query = GranuleQuery() + query.bounding_box(1e-5, 1e-8, 1e-4, 1e-7) + self.assertEqual(query.params["bounding_box"], "0.00001,0.00000001,0.0001,0.0000001") + + def test_polygon_no_scientific_notation(self): + query = GranuleQuery() + query.polygon([(1e-5, 1e-8), (1e-4, 1e-8), (1e-4, 1e-7), (1e-5, 1e-8)]) + self.assertEqual( + query.params["polygon"], + "0.00001,0.00000001,0.0001,0.00000001,0.0001,0.0000001,0.00001,0.00000001", + ) + + def test_line_no_scientific_notation(self): + query = GranuleQuery() + query.line([(1e-5, 1e-8), (1e-4, 1e-7)]) + self.assertEqual(query.params["line"], "0.00001,0.00000001,0.0001,0.0000001") + def test_invalid_spatial_state(self): query = GranuleQuery() diff --git a/tests/test_queries.py b/tests/test_queries.py index 142a026..21739e5 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1,4 +1,7 @@ +import pytest + from cmr import Query +from cmr.queries import _format_float class MockQuery(Query): @@ -55,3 +58,17 @@ def test_token_replaces_existing_auth_header(): query.token("token") assert query.headers["Authorization"] == "token" + + +@pytest.mark.parametrize("value,expected", [ + (1.5, "1.5"), # normal float — no change + (10, "10"), # integer — no change + ("1.5", "1.5"), # string — passed through as-is + (0.0, "0.0"), # zero — no scientific notation + (1e-5, "0.00001"), # small float Python renders as "1e-05" + (1e-8, "0.00000001"), + (-1e-5, "-0.00001"), # negative scientific notation + (1.23e-5, "0.0000123"), +]) +def test_format_float(value, expected): + assert _format_float(value) == expected