Skip to content

Commit 3fd821d

Browse files
Yuri ZmytrakovYuri Zmytrakov
authored andcommitted
fix: round milliseconds instead of truncating
1 parent 0517b4b commit 3fd821d

File tree

3 files changed

+112
-9
lines changed

3 files changed

+112
-9
lines changed

stac_fastapi/core/stac_fastapi/core/datetime_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ def normalize(dt):
2323
return ".."
2424
dt_obj = rfc3339_str_to_datetime(dt)
2525
dt_utc = dt_obj.astimezone(timezone.utc)
26-
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
26+
rounded_dt = dt_utc.replace(microsecond=round(dt_utc.microsecond / 1000) * 1000)
27+
return rounded_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
2728

2829
if not isinstance(date_str, str):
2930
return "../.."

stac_fastapi/sfeos_helpers/stac_fastapi/sfeos_helpers/database/datetime.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
import re
99
from datetime import date
1010
from datetime import datetime as datetime_type
11+
from datetime import timezone
1112
from typing import Dict, Optional, Union
1213

13-
from stac_fastapi.types.rfc3339 import DateTimeType
14+
from stac_fastapi.types.rfc3339 import DateTimeType, rfc3339_str_to_datetime
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -36,6 +37,16 @@ def return_date(
3637
dict: A dictionary representing the date interval for use in filtering search results,
3738
always containing 'gte' and 'lte' keys.
3839
"""
40+
41+
def normalize_datetime(dt_str):
42+
"""Normalize datetime string and preserve millisecond precision."""
43+
if not dt_str or dt_str == "..":
44+
return dt_str
45+
dt_obj = rfc3339_str_to_datetime(dt_str)
46+
dt_utc = dt_obj.astimezone(timezone.utc)
47+
rounded_dt = dt_utc.replace(microsecond=round(dt_utc.microsecond / 1000) * 1000)
48+
return rounded_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
49+
3950
result: Dict[str, Optional[str]] = {"gte": None, "lte": None}
4051

4152
if interval is None:
@@ -44,29 +55,53 @@ def return_date(
4455
if isinstance(interval, str):
4556
if "/" in interval:
4657
parts = interval.split("/")
47-
result["gte"] = (
58+
gte_value = (
4859
parts[0] if parts[0] != ".." else datetime_type.min.isoformat() + "Z"
4960
)
50-
result["lte"] = (
61+
lte_value = (
5162
parts[1]
5263
if len(parts) > 1 and parts[1] != ".."
5364
else datetime_type.max.isoformat() + "Z"
5465
)
66+
67+
result["gte"] = (
68+
normalize_datetime(gte_value)
69+
if gte_value != datetime_type.min.isoformat() + "Z"
70+
else gte_value
71+
)
72+
result["lte"] = (
73+
normalize_datetime(lte_value)
74+
if lte_value != datetime_type.max.isoformat() + "Z"
75+
else lte_value
76+
)
5577
else:
56-
converted_time = interval if interval != ".." else None
78+
converted_time = normalize_datetime(interval) if interval != ".." else ".."
5779
result["gte"] = result["lte"] = converted_time
5880
return result
5981

6082
if isinstance(interval, datetime_type):
61-
datetime_iso = interval.isoformat()
62-
result["gte"] = result["lte"] = datetime_iso
83+
datetime_str = interval.isoformat()
84+
normalized_datetime = normalize_datetime(datetime_str)
85+
result["gte"] = result["lte"] = normalized_datetime
6386
elif isinstance(interval, tuple):
6487
start, end = interval
6588
# Ensure datetimes are converted to UTC and formatted with 'Z'
6689
if start:
67-
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
90+
dt_utc = start.astimezone(timezone.utc)
91+
rounded_dt = dt_utc.replace(
92+
microsecond=round(dt_utc.microsecond / 1000) * 1000
93+
)
94+
result["gte"] = rounded_dt.isoformat(timespec="milliseconds").replace(
95+
"+00:00", "Z"
96+
)
6897
if end:
69-
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
98+
dt_utc = end.astimezone(timezone.utc)
99+
rounded_dt = dt_utc.replace(
100+
microsecond=round(dt_utc.microsecond / 1000) * 1000
101+
)
102+
result["lte"] = rounded_dt.isoformat(timespec="milliseconds").replace(
103+
"+00:00", "Z"
104+
)
70105

71106
return result
72107

stac_fastapi/tests/api/test_api.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,3 +1625,70 @@ async def test_use_datetime_false(app_client, load_test_data, txn_client, monkey
16251625

16261626
assert "test-item-datetime-only" not in found_ids
16271627
assert "test-item-start-end-only" in found_ids
1628+
1629+
1630+
@pytest.mark.asyncio
1631+
async def test_format_datetime_range_microsecond_rounding(
1632+
app_client, txn_client, load_test_data
1633+
):
1634+
"""Test that microseconds are rounded to milliseconds"""
1635+
1636+
test_collection = load_test_data("test_collection.json")
1637+
test_collection_id = "test-collection-microseconds"
1638+
test_collection["id"] = test_collection_id
1639+
await create_collection(txn_client, test_collection)
1640+
1641+
item = load_test_data("test_item.json")
1642+
item["id"] = "test-item-1"
1643+
item["collection"] = test_collection_id
1644+
item["properties"]["datetime"] = "2020-01-01T12:00:00.123Z"
1645+
await create_item(txn_client, item)
1646+
1647+
test_cases = [
1648+
("2020-01-01T12:00:00.123678Z", False),
1649+
("2020-01-01T12:00:00.123499Z", True),
1650+
("2020-01-01T12:00:00.123500Z", False),
1651+
]
1652+
1653+
for datetime_input, should_match in test_cases:
1654+
# Test GET /collections/{id}/items
1655+
resp = await app_client.get(
1656+
f"/collections/{test_collection_id}/items",
1657+
params={"datetime": datetime_input},
1658+
)
1659+
assert resp.status_code == 200
1660+
resp_json = resp.json()
1661+
1662+
if should_match:
1663+
assert len(resp_json["features"]) == 1
1664+
assert resp_json["features"][0]["id"] == "test-item-1"
1665+
else:
1666+
assert len(resp_json["features"]) == 0
1667+
1668+
# Test GET /search
1669+
resp = await app_client.get(
1670+
"/search",
1671+
params={"collections": test_collection_id, "datetime": datetime_input},
1672+
)
1673+
assert resp.status_code == 200
1674+
resp_json = resp.json()
1675+
1676+
if should_match:
1677+
assert len(resp_json["features"]) == 1
1678+
assert resp_json["features"][0]["id"] == "test-item-1"
1679+
else:
1680+
assert len(resp_json["features"]) == 0
1681+
1682+
# Test POST /search
1683+
resp = await app_client.post(
1684+
"/search",
1685+
json={"collections": [test_collection_id], "datetime": datetime_input},
1686+
)
1687+
assert resp.status_code == 200
1688+
resp_json = resp.json()
1689+
1690+
if should_match:
1691+
assert len(resp_json["features"]) == 1
1692+
assert resp_json["features"][0]["id"] == "test-item-1"
1693+
else:
1694+
assert len(resp_json["features"]) == 0

0 commit comments

Comments
 (0)