Skip to content

Commit d3d4b16

Browse files
committed
move date fns, fliter fns
1 parent 5e99c8a commit d3d4b16

File tree

6 files changed

+251
-238
lines changed

6 files changed

+251
-238
lines changed

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 5 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime as datetime_type
55
from datetime import timezone
66
from enum import Enum
7-
from typing import Dict, List, Optional, Set, Type, Union
7+
from typing import List, Optional, Set, Type, Union
88
from urllib.parse import unquote_plus, urljoin
99

1010
import attr
@@ -21,6 +21,7 @@
2121

2222
from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
2323
from stac_fastapi.core.base_settings import ApiBaseSettings
24+
from stac_fastapi.core.datetime_utils import format_datetime_range, return_date
2425
from stac_fastapi.core.models.links import PagingLinks
2526
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
2627
from stac_fastapi.core.session import Session
@@ -35,7 +36,6 @@
3536
from stac_fastapi.types.core import AsyncBaseCoreClient, AsyncBaseTransactionsClient
3637
from stac_fastapi.types.extension import ApiExtension
3738
from stac_fastapi.types.requests import get_base_url
38-
from stac_fastapi.types.rfc3339 import DateTimeType, rfc3339_str_to_datetime
3939
from stac_fastapi.types.search import BaseSearchPostRequest
4040

4141
logger = logging.getLogger(__name__)
@@ -316,7 +316,7 @@ async def item_collection(
316316
)
317317

318318
if datetime:
319-
datetime_search = self._return_date(datetime)
319+
datetime_search = return_date(datetime)
320320
search = self.database.apply_datetime_filter(
321321
search=search, datetime_search=datetime_search
322322
)
@@ -372,87 +372,6 @@ async def get_item(
372372
)
373373
return self.item_serializer.db_to_stac(item, base_url)
374374

375-
@staticmethod
376-
def _return_date(
377-
interval: Optional[Union[DateTimeType, str]]
378-
) -> Dict[str, Optional[str]]:
379-
"""
380-
Convert a date interval.
381-
382-
(which may be a datetime, a tuple of one or two datetimes a string
383-
representing a datetime or range, or None) into a dictionary for filtering
384-
search results with Elasticsearch.
385-
386-
This function ensures the output dictionary contains 'gte' and 'lte' keys,
387-
even if they are set to None, to prevent KeyError in the consuming logic.
388-
389-
Args:
390-
interval (Optional[Union[DateTimeType, str]]): The date interval, which might be a single datetime,
391-
a tuple with one or two datetimes, a string, or None.
392-
393-
Returns:
394-
dict: A dictionary representing the date interval for use in filtering search results,
395-
always containing 'gte' and 'lte' keys.
396-
"""
397-
result: Dict[str, Optional[str]] = {"gte": None, "lte": None}
398-
399-
if interval is None:
400-
return result
401-
402-
if isinstance(interval, str):
403-
if "/" in interval:
404-
parts = interval.split("/")
405-
result["gte"] = parts[0] if parts[0] != ".." else None
406-
result["lte"] = (
407-
parts[1] if len(parts) > 1 and parts[1] != ".." else None
408-
)
409-
else:
410-
converted_time = interval if interval != ".." else None
411-
result["gte"] = result["lte"] = converted_time
412-
return result
413-
414-
if isinstance(interval, datetime_type):
415-
datetime_iso = interval.isoformat()
416-
result["gte"] = result["lte"] = datetime_iso
417-
elif isinstance(interval, tuple):
418-
start, end = interval
419-
# Ensure datetimes are converted to UTC and formatted with 'Z'
420-
if start:
421-
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
422-
if end:
423-
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
424-
425-
return result
426-
427-
def _format_datetime_range(self, date_str: str) -> str:
428-
"""
429-
Convert a datetime range string into a normalized UTC string for API requests using rfc3339_str_to_datetime.
430-
431-
Args:
432-
date_str (str): A string containing two datetime values separated by a '/'.
433-
434-
Returns:
435-
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
436-
"""
437-
438-
def normalize(dt):
439-
dt = dt.strip()
440-
if not dt or dt == "..":
441-
return ".."
442-
dt_obj = rfc3339_str_to_datetime(dt)
443-
dt_utc = dt_obj.astimezone(timezone.utc)
444-
return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
445-
446-
if not isinstance(date_str, str):
447-
return "../.."
448-
if "/" not in date_str:
449-
return f"{normalize(date_str)}/{normalize(date_str)}"
450-
try:
451-
start, end = date_str.split("/", 1)
452-
except Exception:
453-
return "../.."
454-
return f"{normalize(start)}/{normalize(end)}"
455-
456375
async def get_search(
457376
self,
458377
request: Request,
@@ -504,7 +423,7 @@ async def get_search(
504423
}
505424

506425
if datetime:
507-
base_args["datetime"] = self._format_datetime_range(date_str=datetime)
426+
base_args["datetime"] = format_datetime_range(date_str=datetime)
508427

509428
if intersects:
510429
base_args["intersects"] = orjson.loads(unquote_plus(intersects))
@@ -574,7 +493,7 @@ async def post_search(
574493
)
575494

576495
if search_request.datetime:
577-
datetime_search = self._return_date(search_request.datetime)
496+
datetime_search = return_date(search_request.datetime)
578497
search = self.database.apply_datetime_filter(
579498
search=search, datetime_search=datetime_search
580499
)

stac_fastapi/core/stac_fastapi/core/datetime_utils.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,90 @@
1-
"""A few datetime methods."""
2-
from datetime import datetime, timezone
1+
"""Utility functions to handle datetime parsing."""
2+
from datetime import datetime
3+
from datetime import datetime as datetime_type
4+
from datetime import timezone
5+
from typing import Dict, Optional, Union
6+
7+
from stac_fastapi.types.rfc3339 import DateTimeType, rfc3339_str_to_datetime
8+
9+
10+
def return_date(
11+
interval: Optional[Union[DateTimeType, str]]
12+
) -> Dict[str, Optional[str]]:
13+
"""
14+
Convert a date interval.
15+
16+
(which may be a datetime, a tuple of one or two datetimes a string
17+
representing a datetime or range, or None) into a dictionary for filtering
18+
search results with Elasticsearch.
19+
20+
This function ensures the output dictionary contains 'gte' and 'lte' keys,
21+
even if they are set to None, to prevent KeyError in the consuming logic.
22+
23+
Args:
24+
interval (Optional[Union[DateTimeType, str]]): The date interval, which might be a single datetime,
25+
a tuple with one or two datetimes, a string, or None.
26+
27+
Returns:
28+
dict: A dictionary representing the date interval for use in filtering search results,
29+
always containing 'gte' and 'lte' keys.
30+
"""
31+
result: Dict[str, Optional[str]] = {"gte": None, "lte": None}
32+
33+
if interval is None:
34+
return result
35+
36+
if isinstance(interval, str):
37+
if "/" in interval:
38+
parts = interval.split("/")
39+
result["gte"] = parts[0] if parts[0] != ".." else None
40+
result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None
41+
else:
42+
converted_time = interval if interval != ".." else None
43+
result["gte"] = result["lte"] = converted_time
44+
return result
45+
46+
if isinstance(interval, datetime_type):
47+
datetime_iso = interval.isoformat()
48+
result["gte"] = result["lte"] = datetime_iso
49+
elif isinstance(interval, tuple):
50+
start, end = interval
51+
# Ensure datetimes are converted to UTC and formatted with 'Z'
52+
if start:
53+
result["gte"] = start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
54+
if end:
55+
result["lte"] = end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
56+
57+
return result
58+
59+
60+
def format_datetime_range(date_str: str) -> str:
61+
"""
62+
Convert a datetime range string into a normalized UTC string for API requests using rfc3339_str_to_datetime.
63+
64+
Args:
65+
date_str (str): A string containing two datetime values separated by a '/'.
66+
67+
Returns:
68+
str: A string formatted as 'YYYY-MM-DDTHH:MM:SSZ/YYYY-MM-DDTHH:MM:SSZ', with '..' used if any element is None.
69+
"""
70+
71+
def normalize(dt):
72+
dt = dt.strip()
73+
if not dt or dt == "..":
74+
return ".."
75+
dt_obj = rfc3339_str_to_datetime(dt)
76+
dt_utc = dt_obj.astimezone(timezone.utc)
77+
return dt_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
78+
79+
if not isinstance(date_str, str):
80+
return "../.."
81+
if "/" not in date_str:
82+
return f"{normalize(date_str)}/{normalize(date_str)}"
83+
try:
84+
start, end = date_str.split("/", 1)
85+
except Exception:
86+
return "../.."
87+
return f"{normalize(start)}/{normalize(end)}"
388

489

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

stac_fastapi/core/stac_fastapi/core/extensions/filter.py

Lines changed: 45 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,51 @@
1717
from enum import Enum
1818
from typing import Any, Dict
1919

20+
DEFAULT_QUERYABLES: Dict[str, Dict[str, Any]] = {
21+
"id": {
22+
"description": "ID",
23+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/properties/id",
24+
},
25+
"collection": {
26+
"description": "Collection",
27+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/then/properties/collection",
28+
},
29+
"geometry": {
30+
"description": "Geometry",
31+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/1/oneOf/0/properties/geometry",
32+
},
33+
"datetime": {
34+
"description": "Acquisition Timestamp",
35+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/datetime",
36+
},
37+
"created": {
38+
"description": "Creation Timestamp",
39+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/created",
40+
},
41+
"updated": {
42+
"description": "Creation Timestamp",
43+
"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/datetime.json#/properties/updated",
44+
},
45+
"cloud_cover": {
46+
"description": "Cloud Cover",
47+
"$ref": "https://stac-extensions.github.io/eo/v1.0.0/schema.json#/definitions/fields/properties/eo:cloud_cover",
48+
},
49+
"cloud_shadow_percentage": {
50+
"title": "Cloud Shadow Percentage",
51+
"description": "Cloud Shadow Percentage",
52+
"type": "number",
53+
"minimum": 0,
54+
"maximum": 100,
55+
},
56+
"nodata_pixel_percentage": {
57+
"title": "No Data Pixel Percentage",
58+
"description": "No Data Pixel Percentage",
59+
"type": "number",
60+
"minimum": 0,
61+
"maximum": 100,
62+
},
63+
}
64+
2065
_cql2_like_patterns = re.compile(r"\\.|[%_]|\\$")
2166
_valid_like_substitutions = {
2267
"\\\\": "\\",
@@ -115,105 +160,3 @@ def to_es_field(field: str) -> str:
115160
str: The mapped field name suitable for Elasticsearch queries.
116161
"""
117162
return queryables_mapping.get(field, field)
118-
119-
120-
def to_es(query: Dict[str, Any]) -> Dict[str, Any]:
121-
"""
122-
Transform a simplified CQL2 query structure to an Elasticsearch compatible query DSL.
123-
124-
Args:
125-
query (Dict[str, Any]): The query dictionary containing 'op' and 'args'.
126-
127-
Returns:
128-
Dict[str, Any]: The corresponding Elasticsearch query in the form of a dictionary.
129-
"""
130-
if query["op"] in [LogicalOp.AND, LogicalOp.OR, LogicalOp.NOT]:
131-
bool_type = {
132-
LogicalOp.AND: "must",
133-
LogicalOp.OR: "should",
134-
LogicalOp.NOT: "must_not",
135-
}[query["op"]]
136-
return {"bool": {bool_type: [to_es(sub_query) for sub_query in query["args"]]}}
137-
138-
elif query["op"] in [
139-
ComparisonOp.EQ,
140-
ComparisonOp.NEQ,
141-
ComparisonOp.LT,
142-
ComparisonOp.LTE,
143-
ComparisonOp.GT,
144-
ComparisonOp.GTE,
145-
]:
146-
range_op = {
147-
ComparisonOp.LT: "lt",
148-
ComparisonOp.LTE: "lte",
149-
ComparisonOp.GT: "gt",
150-
ComparisonOp.GTE: "gte",
151-
}
152-
153-
field = to_es_field(query["args"][0]["property"])
154-
value = query["args"][1]
155-
if isinstance(value, dict) and "timestamp" in value:
156-
value = value["timestamp"]
157-
if query["op"] == ComparisonOp.EQ:
158-
return {"range": {field: {"gte": value, "lte": value}}}
159-
elif query["op"] == ComparisonOp.NEQ:
160-
return {
161-
"bool": {
162-
"must_not": [{"range": {field: {"gte": value, "lte": value}}}]
163-
}
164-
}
165-
else:
166-
return {"range": {field: {range_op[query["op"]]: value}}}
167-
else:
168-
if query["op"] == ComparisonOp.EQ:
169-
return {"term": {field: value}}
170-
elif query["op"] == ComparisonOp.NEQ:
171-
return {"bool": {"must_not": [{"term": {field: value}}]}}
172-
else:
173-
return {"range": {field: {range_op[query["op"]]: value}}}
174-
175-
elif query["op"] == ComparisonOp.IS_NULL:
176-
field = to_es_field(query["args"][0]["property"])
177-
return {"bool": {"must_not": {"exists": {"field": field}}}}
178-
179-
elif query["op"] == AdvancedComparisonOp.BETWEEN:
180-
field = to_es_field(query["args"][0]["property"])
181-
gte, lte = query["args"][1], query["args"][2]
182-
if isinstance(gte, dict) and "timestamp" in gte:
183-
gte = gte["timestamp"]
184-
if isinstance(lte, dict) and "timestamp" in lte:
185-
lte = lte["timestamp"]
186-
return {"range": {field: {"gte": gte, "lte": lte}}}
187-
188-
elif query["op"] == AdvancedComparisonOp.IN:
189-
field = to_es_field(query["args"][0]["property"])
190-
values = query["args"][1]
191-
if not isinstance(values, list):
192-
raise ValueError(f"Arg {values} is not a list")
193-
return {"terms": {field: values}}
194-
195-
elif query["op"] == AdvancedComparisonOp.LIKE:
196-
field = to_es_field(query["args"][0]["property"])
197-
pattern = cql2_like_to_es(query["args"][1])
198-
return {"wildcard": {field: {"value": pattern, "case_insensitive": True}}}
199-
200-
elif query["op"] in [
201-
SpatialOp.S_INTERSECTS,
202-
SpatialOp.S_CONTAINS,
203-
SpatialOp.S_WITHIN,
204-
SpatialOp.S_DISJOINT,
205-
]:
206-
field = to_es_field(query["args"][0]["property"])
207-
geometry = query["args"][1]
208-
209-
relation_mapping = {
210-
SpatialOp.S_INTERSECTS: "intersects",
211-
SpatialOp.S_CONTAINS: "contains",
212-
SpatialOp.S_WITHIN: "within",
213-
SpatialOp.S_DISJOINT: "disjoint",
214-
}
215-
216-
relation = relation_mapping[query["op"]]
217-
return {"geo_shape": {field: {"shape": geometry, "relation": relation}}}
218-
219-
return {}

0 commit comments

Comments
 (0)