Skip to content

Commit 3ad2c7d

Browse files
Yuri ZmytrakovYuri Zmytrakov
authored andcommitted
fix: maintain backward compatibility for datetime searches
1 parent 407bcf6 commit 3ad2c7d

File tree

3 files changed

+158
-93
lines changed

3 files changed

+158
-93
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ You can customize additional settings in your `.env` file:
366366
| `STAC_DEFAULT_ITEM_LIMIT` | Configures the default number of STAC items returned when no limit parameter is specified in the request. | `10` | Optional |
367367
| `STAC_INDEX_ASSETS` | Controls if Assets are indexed when added to Elasticsearch/Opensearch. This allows asset fields to be included in search queries. | `false` | Optional |
368368
| `USE_DATETIME` | Configures the datetime search behavior in SFEOS. When enabled, searches both datetime field and falls back to start_datetime/end_datetime range for items with null datetime. When disabled, searches only by start_datetime/end_datetime range. | `true` | Optional |
369+
| `USE_DATETIME_NANOS` | Enables nanosecond precision handling for `datetime` field searches as per the `date_nanos` type. When `False`, it uses 3 millisecond precision as per the type `date`. | `true` | Optional |
369370
| `EXCLUDED_FROM_QUERYABLES` | Comma-separated list of fully qualified field names to exclude from the queryables endpoint and filtering. Use full paths like `properties.auth:schemes,properties.storage:schemes`. Excluded fields and their nested children will not be exposed in queryables. | None | Optional |
370371
| `EXCLUDED_FROM_ITEMS` | Specifies fields to exclude from STAC item responses. Supports comma-separated field names and dot notation for nested fields (e.g., `private_data,properties.confidential,assets.internal`). | `None` | Optional |
371372

stac_fastapi/core/stac_fastapi/core/datetime_utils.py

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datetime import datetime, timezone
44

5+
from stac_fastapi.core.utilities import get_bool_env
56
from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime
67

78

@@ -15,45 +16,71 @@ def format_datetime_range(date_str: str) -> str:
1516
Returns:
1617
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
1718
"""
18-
MIN_DATE_NANOS = datetime(1970, 1, 1, tzinfo=timezone.utc)
19-
MAX_DATE_NANOS = datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
20-
21-
def normalize(dt):
22-
"""Normalize datetime string and preserve millisecond precision."""
23-
dt = dt.strip()
24-
if not dt or dt == "..":
25-
return ".."
26-
dt_utc = rfc3339_str_to_datetime(dt).astimezone(timezone.utc)
27-
if dt_utc < MIN_DATE_NANOS:
28-
dt_utc = MIN_DATE_NANOS
29-
if dt_utc > MAX_DATE_NANOS:
30-
dt_utc = MAX_DATE_NANOS
31-
return dt_utc.isoformat(timespec="auto").replace("+00:00", "Z")
32-
33-
if not isinstance(date_str, str):
34-
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
35-
36-
if "/" not in date_str:
37-
return f"{normalize(date_str)}/{normalize(date_str)}"
38-
39-
try:
40-
start, end = date_str.split("/", 1)
41-
except Exception:
42-
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
43-
44-
normalized_start = normalize(start)
45-
normalized_end = normalize(end)
46-
47-
if normalized_start == "..":
48-
normalized_start = MIN_DATE_NANOS.isoformat(timespec="auto").replace(
49-
"+00:00", "Z"
50-
)
51-
if normalized_end == "..":
52-
normalized_end = MAX_DATE_NANOS.isoformat(timespec="auto").replace(
53-
"+00:00", "Z"
54-
)
55-
56-
return f"{normalized_start}/{normalized_end}"
19+
use_datetime_nanos = get_bool_env("USE_DATETIME_NANOS", default=True)
20+
21+
if use_datetime_nanos:
22+
MIN_DATE_NANOS = datetime(1970, 1, 1, tzinfo=timezone.utc)
23+
MAX_DATE_NANOS = datetime(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
24+
25+
def normalize(dt):
26+
"""Normalize datetime string and preserve nano second precision."""
27+
dt = dt.strip()
28+
if not dt or dt == "..":
29+
return ".."
30+
dt_utc = rfc3339_str_to_datetime(dt).astimezone(timezone.utc)
31+
if dt_utc < MIN_DATE_NANOS:
32+
dt_utc = MIN_DATE_NANOS
33+
if dt_utc > MAX_DATE_NANOS:
34+
dt_utc = MAX_DATE_NANOS
35+
return dt_utc.isoformat(timespec="auto").replace("+00:00", "Z")
36+
37+
if not isinstance(date_str, str):
38+
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
39+
40+
if "/" not in date_str:
41+
return f"{normalize(date_str)}/{normalize(date_str)}"
42+
43+
try:
44+
start, end = date_str.split("/", 1)
45+
except Exception:
46+
return f"{MIN_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}/{MAX_DATE_NANOS.isoformat(timespec='auto').replace('+00:00','Z')}"
47+
48+
normalized_start = normalize(start)
49+
normalized_end = normalize(end)
50+
51+
if normalized_start == "..":
52+
normalized_start = MIN_DATE_NANOS.isoformat(timespec="auto").replace(
53+
"+00:00", "Z"
54+
)
55+
if normalized_end == "..":
56+
normalized_end = MAX_DATE_NANOS.isoformat(timespec="auto").replace(
57+
"+00:00", "Z"
58+
)
59+
60+
return f"{normalized_start}/{normalized_end}"
61+
62+
else:
63+
64+
def normalize(dt):
65+
"""Normalize datetime string and preserve millisecond precision."""
66+
dt = dt.strip()
67+
if not dt or dt == "..":
68+
return ".."
69+
dt_obj = rfc3339_str_to_datetime(dt)
70+
dt_utc = dt_obj.astimezone(timezone.utc)
71+
return dt_utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
72+
73+
if not isinstance(date_str, str):
74+
return "../.."
75+
76+
if "/" not in date_str:
77+
return f"{normalize(date_str)}/{normalize(date_str)}"
78+
79+
try:
80+
start, end = date_str.split("/", 1)
81+
except Exception:
82+
return "../.."
83+
return f"{normalize(start)}/{normalize(end)}"
5784

5885

5986
# Borrowed from pystac - https://github.com/stac-utils/pystac/blob/f5e4cf4a29b62e9ef675d4a4dac7977b09f53c8f/pystac/utils.py#L370-L394

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

Lines changed: 91 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from datetime import timezone
1212
from typing import Dict, Optional, Union
1313

14+
from stac_fastapi.core.utilities import get_bool_env
1415
from stac_fastapi.types.rfc3339 import DateTimeType
1516

1617
logger = logging.getLogger(__name__)
@@ -38,67 +39,103 @@ def return_date(
3839
always containing 'gte' and 'lte' keys.
3940
"""
4041
result: Dict[str, Optional[str]] = {"gte": None, "lte": None}
41-
MIN_DATE_NANOS = datetime_type(1970, 1, 1, tzinfo=timezone.utc)
42-
MAX_DATE_NANOS = datetime_type(2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc)
43-
42+
use_datetime_nanos = get_bool_env("USE_DATETIME_NANOS", default=True)
4443
if interval is None:
4544
return result
4645

47-
if isinstance(interval, str):
48-
if "/" in interval:
49-
parts = interval.split("/")
50-
result["gte"] = (
51-
parts[0] if parts[0] != ".." else MIN_DATE_NANOS.isoformat() + "Z"
52-
)
53-
result["lte"] = (
54-
parts[1]
55-
if len(parts) > 1 and parts[1] != ".."
56-
else MAX_DATE_NANOS.isoformat() + "Z"
46+
if use_datetime_nanos:
47+
MIN_DATE_NANOS = datetime_type(1970, 1, 1, tzinfo=timezone.utc)
48+
MAX_DATE_NANOS = datetime_type(
49+
2262, 4, 11, 23, 47, 16, 854775, tzinfo=timezone.utc
50+
)
51+
52+
if isinstance(interval, str):
53+
if "/" in interval:
54+
parts = interval.split("/")
55+
result["gte"] = (
56+
parts[0] if parts[0] != ".." else MIN_DATE_NANOS.isoformat() + "Z"
57+
)
58+
result["lte"] = (
59+
parts[1]
60+
if len(parts) > 1 and parts[1] != ".."
61+
else MAX_DATE_NANOS.isoformat() + "Z"
62+
)
63+
else:
64+
converted_time = interval if interval != ".." else None
65+
result["gte"] = result["lte"] = converted_time
66+
return result
67+
68+
if isinstance(interval, datetime_type):
69+
dt_utc = (
70+
interval.astimezone(timezone.utc)
71+
if interval.tzinfo
72+
else interval.replace(tzinfo=timezone.utc)
5773
)
58-
else:
59-
converted_time = interval if interval != ".." else None
60-
result["gte"] = result["lte"] = converted_time
74+
if dt_utc < MIN_DATE_NANOS:
75+
dt_utc = MIN_DATE_NANOS
76+
elif dt_utc > MAX_DATE_NANOS:
77+
dt_utc = MAX_DATE_NANOS
78+
datetime_iso = dt_utc.isoformat()
79+
result["gte"] = result["lte"] = datetime_iso
80+
elif isinstance(interval, tuple):
81+
start, end = interval
82+
# Ensure datetimes are converted to UTC and formatted with 'Z'
83+
if start:
84+
start_utc = (
85+
start.astimezone(timezone.utc)
86+
if start.tzinfo
87+
else start.replace(tzinfo=timezone.utc)
88+
)
89+
if start_utc < MIN_DATE_NANOS:
90+
start_utc = MIN_DATE_NANOS
91+
elif start_utc > MAX_DATE_NANOS:
92+
start_utc = MAX_DATE_NANOS
93+
result["gte"] = start_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
94+
if end:
95+
end_utc = (
96+
end.astimezone(timezone.utc)
97+
if end.tzinfo
98+
else end.replace(tzinfo=timezone.utc)
99+
)
100+
if end_utc < MIN_DATE_NANOS:
101+
end_utc = MIN_DATE_NANOS
102+
elif end_utc > MAX_DATE_NANOS:
103+
end_utc = MAX_DATE_NANOS
104+
result["lte"] = end_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
105+
61106
return result
62107

63-
if isinstance(interval, datetime_type):
64-
dt_utc = (
65-
interval.astimezone(timezone.utc)
66-
if interval.tzinfo
67-
else interval.replace(tzinfo=timezone.utc)
68-
)
69-
if dt_utc < MIN_DATE_NANOS:
70-
dt_utc = MIN_DATE_NANOS
71-
elif dt_utc > MAX_DATE_NANOS:
72-
dt_utc = MAX_DATE_NANOS
73-
datetime_iso = dt_utc.isoformat()
74-
result["gte"] = result["lte"] = datetime_iso
75-
elif isinstance(interval, tuple):
76-
start, end = interval
77-
# Ensure datetimes are converted to UTC and formatted with 'Z'
78-
if start:
79-
start_utc = (
80-
start.astimezone(timezone.utc)
81-
if start.tzinfo
82-
else start.replace(tzinfo=timezone.utc)
83-
)
84-
if start_utc < MIN_DATE_NANOS:
85-
start_utc = MIN_DATE_NANOS
86-
elif start_utc > MAX_DATE_NANOS:
87-
start_utc = MAX_DATE_NANOS
88-
result["gte"] = start_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
89-
if end:
90-
end_utc = (
91-
end.astimezone(timezone.utc)
92-
if end.tzinfo
93-
else end.replace(tzinfo=timezone.utc)
94-
)
95-
if end_utc < MIN_DATE_NANOS:
96-
end_utc = MIN_DATE_NANOS
97-
elif end_utc > MAX_DATE_NANOS:
98-
end_utc = MAX_DATE_NANOS
99-
result["lte"] = end_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
108+
else:
109+
if isinstance(interval, str):
110+
if "/" in interval:
111+
parts = interval.split("/")
112+
result["gte"] = (
113+
parts[0]
114+
if parts[0] != ".."
115+
else datetime_type.min.isoformat() + "Z"
116+
)
117+
result["lte"] = (
118+
parts[1]
119+
if len(parts) > 1 and parts[1] != ".."
120+
else datetime_type.max.isoformat() + "Z"
121+
)
122+
else:
123+
converted_time = interval if interval != ".." else None
124+
result["gte"] = result["lte"] = converted_time
125+
return result
126+
127+
if isinstance(interval, datetime_type):
128+
datetime_iso = interval.isoformat()
129+
result["gte"] = result["lte"] = datetime_iso
130+
elif isinstance(interval, tuple):
131+
start, end = interval
132+
# Ensure datetimes are converted to UTC and formatted with 'Z'
133+
if start:
134+
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
135+
if end:
136+
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
100137

101-
return result
138+
return result
102139

103140

104141
def extract_date(date_str: str) -> date:

0 commit comments

Comments
 (0)