sfeos-helpers 6.9.0__py3-none-any.whl → 6.10.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sfeos_helpers-6.9.0.dist-info → sfeos_helpers-6.10.1.dist-info}/METADATA +2 -2
- {sfeos_helpers-6.9.0.dist-info → sfeos_helpers-6.10.1.dist-info}/RECORD +19 -18
- stac_fastapi/sfeos_helpers/aggregation/client.py +1 -3
- stac_fastapi/sfeos_helpers/database/__init__.py +32 -2
- stac_fastapi/sfeos_helpers/database/catalogs.py +190 -0
- stac_fastapi/sfeos_helpers/database/datetime.py +54 -1
- stac_fastapi/sfeos_helpers/database/index.py +88 -40
- stac_fastapi/sfeos_helpers/database/query.py +1 -1
- stac_fastapi/sfeos_helpers/database/utils.py +97 -2
- stac_fastapi/sfeos_helpers/mappings.py +2 -2
- stac_fastapi/sfeos_helpers/search_engine/base.py +30 -0
- stac_fastapi/sfeos_helpers/search_engine/index_operations.py +80 -25
- stac_fastapi/sfeos_helpers/search_engine/inserters.py +175 -95
- stac_fastapi/sfeos_helpers/search_engine/managers.py +340 -56
- stac_fastapi/sfeos_helpers/search_engine/selection/base.py +7 -3
- stac_fastapi/sfeos_helpers/search_engine/selection/cache_manager.py +82 -25
- stac_fastapi/sfeos_helpers/search_engine/selection/selectors.py +71 -15
- stac_fastapi/sfeos_helpers/version.py +1 -1
- {sfeos_helpers-6.9.0.dist-info → sfeos_helpers-6.10.1.dist-info}/WHEEL +0 -0
|
@@ -5,7 +5,7 @@ in Elasticsearch/OpenSearch, such as parameter validation.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
from typing import Any, Dict, List, Union
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
9
|
|
|
10
10
|
from stac_fastapi.core.utilities import bbox2polygon, get_bool_env
|
|
11
11
|
from stac_fastapi.extensions.core.transaction.request import (
|
|
@@ -14,10 +14,73 @@ from stac_fastapi.extensions.core.transaction.request import (
|
|
|
14
14
|
PatchRemove,
|
|
15
15
|
)
|
|
16
16
|
from stac_fastapi.sfeos_helpers.models.patch import ElasticPath, ESCommandSet
|
|
17
|
+
from stac_fastapi.types.errors import ConflictError
|
|
17
18
|
|
|
18
19
|
logger = logging.getLogger(__name__)
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
class ItemAlreadyExistsError(ConflictError):
|
|
23
|
+
"""Error raised when attempting to create an item that already exists.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
item_id: The ID of the item that already exists.
|
|
27
|
+
collection_id: The ID of the collection containing the item.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, item_id: str, collection_id: str):
|
|
31
|
+
"""Initialize the error with item and collection IDs."""
|
|
32
|
+
self.item_id = item_id
|
|
33
|
+
self.collection_id = collection_id
|
|
34
|
+
message = f"Item {item_id} in collection {collection_id} already exists"
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def check_item_exists_in_alias(client: Any, alias: str, doc_id: str) -> bool:
|
|
39
|
+
"""Check if an item exists across all indexes for an alias.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client: The async Elasticsearch/OpenSearch client.
|
|
43
|
+
alias: The index alias to search against.
|
|
44
|
+
doc_id: The document ID to check for existence.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
bool: True if the item exists in any index under the alias, False otherwise.
|
|
48
|
+
"""
|
|
49
|
+
resp = await client.search(
|
|
50
|
+
index=alias,
|
|
51
|
+
body={
|
|
52
|
+
"query": {"ids": {"values": [doc_id]}},
|
|
53
|
+
"_source": False,
|
|
54
|
+
},
|
|
55
|
+
size=0,
|
|
56
|
+
terminate_after=1,
|
|
57
|
+
)
|
|
58
|
+
return bool(resp["hits"]["total"]["value"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_item_exists_in_alias_sync(client: Any, alias: str, doc_id: str) -> bool:
|
|
62
|
+
"""Check if an item exists across all indexes for an alias (sync).
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
client: The sync Elasticsearch/OpenSearch client.
|
|
66
|
+
alias: The index alias to search against.
|
|
67
|
+
doc_id: The document ID to check for existence.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
bool: True if the item exists in any index under the alias, False otherwise.
|
|
71
|
+
"""
|
|
72
|
+
resp = client.search(
|
|
73
|
+
index=alias,
|
|
74
|
+
body={
|
|
75
|
+
"query": {"ids": {"values": [doc_id]}},
|
|
76
|
+
"_source": False,
|
|
77
|
+
},
|
|
78
|
+
size=0,
|
|
79
|
+
terminate_after=1,
|
|
80
|
+
)
|
|
81
|
+
return bool(resp["hits"]["total"]["value"])
|
|
82
|
+
|
|
83
|
+
|
|
21
84
|
def add_bbox_shape_to_collection(collection: Dict[str, Any]) -> bool:
|
|
22
85
|
"""Add bbox_shape field to a collection document for spatial queries.
|
|
23
86
|
|
|
@@ -354,10 +417,42 @@ def operations_to_script(operations: List, create_nest: bool = False) -> Dict:
|
|
|
354
417
|
commands=commands, operation=operation, path=path, params=params
|
|
355
418
|
)
|
|
356
419
|
|
|
357
|
-
|
|
420
|
+
source = "".join(commands)
|
|
358
421
|
|
|
359
422
|
return {
|
|
360
423
|
"source": source,
|
|
361
424
|
"lang": "painless",
|
|
362
425
|
"params": params,
|
|
363
426
|
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def add_hidden_filter(
|
|
430
|
+
query: Optional[Dict[str, Any]] = None, hide_item_path: Optional[str] = None
|
|
431
|
+
) -> Dict[str, Any]:
|
|
432
|
+
"""Add hidden filter to a query to exclude hidden items.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
query: Optional Elasticsearch query to combine with hidden filter
|
|
436
|
+
hide_item_path: Path to the hidden field (e.g., "properties._private.hidden")
|
|
437
|
+
If None or empty, return original query (no filtering)
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Query with hidden filter applied
|
|
441
|
+
"""
|
|
442
|
+
if not hide_item_path:
|
|
443
|
+
return query or {"match_all": {}}
|
|
444
|
+
|
|
445
|
+
hidden_filter = {
|
|
446
|
+
"bool": {
|
|
447
|
+
"should": [
|
|
448
|
+
{"term": {hide_item_path: False}},
|
|
449
|
+
{"bool": {"must_not": {"exists": {"field": hide_item_path}}}},
|
|
450
|
+
],
|
|
451
|
+
"minimum_should_match": 1,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if query:
|
|
456
|
+
return {"bool": {"must": [query, hidden_filter]}}
|
|
457
|
+
else:
|
|
458
|
+
return hidden_filter
|
|
@@ -268,8 +268,8 @@ _BASE_ITEMS_MAPPINGS = {
|
|
|
268
268
|
"properties": {
|
|
269
269
|
# Common https://github.com/radiantearth/stac-spec/blob/master/item-spec/common-metadata.md
|
|
270
270
|
"datetime": {"type": "date_nanos"},
|
|
271
|
-
"start_datetime": {"type": "
|
|
272
|
-
"end_datetime": {"type": "
|
|
271
|
+
"start_datetime": {"type": "date_nanos"},
|
|
272
|
+
"end_datetime": {"type": "date_nanos"},
|
|
273
273
|
"created": {"type": "date"},
|
|
274
274
|
"updated": {"type": "date"},
|
|
275
275
|
# Satellite Extension https://github.com/stac-extensions/sat
|
|
@@ -49,3 +49,33 @@ class BaseIndexInserter(ABC):
|
|
|
49
49
|
str: Created index name.
|
|
50
50
|
"""
|
|
51
51
|
pass
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def should_create_collection_index() -> bool:
|
|
56
|
+
"""Whether this strategy requires collection index creation.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
bool: True if strategy creates collection indexes, False otherwise.
|
|
60
|
+
"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
async def refresh_cache(self) -> None:
|
|
64
|
+
"""Refresh internal cache if applicable.
|
|
65
|
+
|
|
66
|
+
Default implementation does nothing. Subclasses that maintain
|
|
67
|
+
internal caches should override this method.
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def validate_datetime_field_update(self, field_path: str) -> None:
|
|
72
|
+
"""Validate if a datetime field can be updated.
|
|
73
|
+
|
|
74
|
+
For datetime-based indexing, certain datetime fields cannot be modified
|
|
75
|
+
because they determine the index where the item is stored.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
field_path (str): The path of the field being updated (e.g., "properties.datetime").
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
pass
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"""Search engine adapters for different implementations."""
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
-
from typing import Any, Dict
|
|
4
|
+
from typing import Any, Dict, List, Literal
|
|
5
5
|
|
|
6
|
+
from stac_fastapi.core.utilities import get_bool_env
|
|
6
7
|
from stac_fastapi.sfeos_helpers.database import (
|
|
7
8
|
index_alias_by_collection_id,
|
|
8
9
|
index_by_collection_id,
|
|
@@ -18,6 +19,16 @@ from stac_fastapi.sfeos_helpers.mappings import (
|
|
|
18
19
|
class IndexOperations:
|
|
19
20
|
"""Base class for search engine adapters with common implementations."""
|
|
20
21
|
|
|
22
|
+
@property
|
|
23
|
+
def use_datetime(self) -> bool:
|
|
24
|
+
"""Get USE_DATETIME setting dynamically."""
|
|
25
|
+
return get_bool_env("USE_DATETIME", default=True)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def primary_datetime_name(self) -> str:
|
|
29
|
+
"""Get primary datetime field name based on current USE_DATETIME setting."""
|
|
30
|
+
return "datetime" if self.use_datetime else "start_datetime"
|
|
31
|
+
|
|
21
32
|
async def create_simple_index(self, client: Any, collection_id: str) -> str:
|
|
22
33
|
"""Create a simple index for the given collection.
|
|
23
34
|
|
|
@@ -45,26 +56,51 @@ class IndexOperations:
|
|
|
45
56
|
return index_name
|
|
46
57
|
|
|
47
58
|
async def create_datetime_index(
|
|
48
|
-
self,
|
|
59
|
+
self,
|
|
60
|
+
client: Any,
|
|
61
|
+
collection_id: str,
|
|
62
|
+
start_datetime: str | None,
|
|
63
|
+
datetime: str | None,
|
|
64
|
+
end_datetime: str | None,
|
|
49
65
|
) -> str:
|
|
50
66
|
"""Create a datetime-based index for the given collection.
|
|
51
67
|
|
|
52
68
|
Args:
|
|
53
69
|
client: Search engine client instance.
|
|
54
70
|
collection_id (str): Collection identifier.
|
|
55
|
-
|
|
71
|
+
start_datetime (str | None): Start datetime for the index alias.
|
|
72
|
+
datetime (str | None): Datetime for the datetime alias.
|
|
73
|
+
end_datetime (str | None): End datetime for the index alias.
|
|
56
74
|
|
|
57
75
|
Returns:
|
|
58
|
-
str: Created
|
|
76
|
+
str: Created datetime alias name.
|
|
59
77
|
"""
|
|
60
78
|
index_name = self.create_index_name(collection_id)
|
|
61
|
-
alias_name = self.create_alias_name(collection_id, start_date)
|
|
62
79
|
collection_alias = index_alias_by_collection_id(collection_id)
|
|
80
|
+
|
|
81
|
+
aliases: Dict[str, Any] = {
|
|
82
|
+
collection_alias: {},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if start_datetime:
|
|
86
|
+
alias_start_date = self.create_alias_name(
|
|
87
|
+
collection_id, "start_datetime", start_datetime
|
|
88
|
+
)
|
|
89
|
+
alias_end_date = self.create_alias_name(
|
|
90
|
+
collection_id, "end_datetime", end_datetime
|
|
91
|
+
)
|
|
92
|
+
aliases[alias_start_date] = {}
|
|
93
|
+
aliases[alias_end_date] = {}
|
|
94
|
+
created_alias = alias_start_date
|
|
95
|
+
else:
|
|
96
|
+
created_alias = self.create_alias_name(collection_id, "datetime", datetime)
|
|
97
|
+
aliases[created_alias] = {}
|
|
98
|
+
|
|
63
99
|
await client.indices.create(
|
|
64
100
|
index=index_name,
|
|
65
|
-
body=self._create_index_body(
|
|
101
|
+
body=self._create_index_body(aliases),
|
|
66
102
|
)
|
|
67
|
-
return
|
|
103
|
+
return created_alias
|
|
68
104
|
|
|
69
105
|
@staticmethod
|
|
70
106
|
async def update_index_alias(client: Any, end_date: str, old_alias: str) -> str:
|
|
@@ -90,23 +126,33 @@ class IndexOperations:
|
|
|
90
126
|
return new_alias
|
|
91
127
|
|
|
92
128
|
@staticmethod
|
|
93
|
-
async def change_alias_name(
|
|
94
|
-
|
|
129
|
+
async def change_alias_name(
|
|
130
|
+
client: Any,
|
|
131
|
+
old_start_datetime_alias: str,
|
|
132
|
+
aliases_to_change: List[str],
|
|
133
|
+
aliases_to_create: List[str],
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Change alias names by removing old aliases and adding new ones.
|
|
95
136
|
|
|
96
137
|
Args:
|
|
97
138
|
client: Search engine client instance.
|
|
98
|
-
|
|
99
|
-
|
|
139
|
+
old_start_datetime_alias (str): Current start_datetime alias name to identify the index.
|
|
140
|
+
aliases_to_change (List[str]): List of old alias names to remove.
|
|
141
|
+
aliases_to_create (List[str]): List of new alias names to add.
|
|
100
142
|
|
|
101
143
|
Returns:
|
|
102
144
|
None
|
|
103
145
|
"""
|
|
104
|
-
aliases_info = await client.indices.get_alias(name=
|
|
105
|
-
|
|
146
|
+
aliases_info = await client.indices.get_alias(name=old_start_datetime_alias)
|
|
147
|
+
index_name = list(aliases_info.keys())[0]
|
|
106
148
|
|
|
107
|
-
|
|
149
|
+
actions = []
|
|
150
|
+
for old_alias in aliases_to_change:
|
|
108
151
|
actions.append({"remove": {"index": index_name, "alias": old_alias}})
|
|
152
|
+
|
|
153
|
+
for new_alias in aliases_to_create:
|
|
109
154
|
actions.append({"add": {"index": index_name, "alias": new_alias}})
|
|
155
|
+
|
|
110
156
|
await client.indices.update_aliases(body={"actions": actions})
|
|
111
157
|
|
|
112
158
|
@staticmethod
|
|
@@ -123,18 +169,23 @@ class IndexOperations:
|
|
|
123
169
|
return f"{ITEMS_INDEX_PREFIX}{cleaned.lower()}_{uuid.uuid4()}"
|
|
124
170
|
|
|
125
171
|
@staticmethod
|
|
126
|
-
def create_alias_name(
|
|
127
|
-
|
|
172
|
+
def create_alias_name(
|
|
173
|
+
collection_id: str,
|
|
174
|
+
name: Literal["start_datetime", "datetime", "end_datetime"],
|
|
175
|
+
start_date: str,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Create alias name from collection ID and date.
|
|
128
178
|
|
|
129
179
|
Args:
|
|
130
180
|
collection_id (str): Collection identifier.
|
|
131
|
-
|
|
181
|
+
name (Literal["start_datetime", "datetime", "end_datetime"]): Type of alias to create.
|
|
182
|
+
start_date (str): Date value for the alias.
|
|
132
183
|
|
|
133
184
|
Returns:
|
|
134
|
-
str:
|
|
185
|
+
str: Formatted alias name with prefix, type, collection ID, and date.
|
|
135
186
|
"""
|
|
136
187
|
cleaned = collection_id.translate(_ES_INDEX_NAME_UNSUPPORTED_CHARS_TABLE)
|
|
137
|
-
return f"{ITEMS_INDEX_PREFIX}{cleaned.lower()}_{start_date}"
|
|
188
|
+
return f"{ITEMS_INDEX_PREFIX}{name}_{cleaned.lower()}_{start_date}"
|
|
138
189
|
|
|
139
190
|
@staticmethod
|
|
140
191
|
def _create_index_body(aliases: Dict[str, Dict]) -> Dict[str, Any]:
|
|
@@ -152,21 +203,25 @@ class IndexOperations:
|
|
|
152
203
|
"settings": ES_ITEMS_SETTINGS,
|
|
153
204
|
}
|
|
154
205
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
206
|
+
async def find_latest_item_in_index(
|
|
207
|
+
self, client: Any, index_name: str
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""Find the latest item in the specified index.
|
|
158
210
|
|
|
159
211
|
Args:
|
|
160
212
|
client: Search engine client instance.
|
|
161
213
|
index_name (str): Name of the index to query.
|
|
162
214
|
|
|
163
215
|
Returns:
|
|
164
|
-
|
|
216
|
+
dict[str, Any]: Latest item document from the index with metadata.
|
|
165
217
|
"""
|
|
166
218
|
query = {
|
|
167
219
|
"size": 1,
|
|
168
|
-
"sort": [{"properties.
|
|
169
|
-
"_source": [
|
|
220
|
+
"sort": [{f"properties.{self.primary_datetime_name}": {"order": "desc"}}],
|
|
221
|
+
"_source": [
|
|
222
|
+
"properties.start_datetime",
|
|
223
|
+
"properties.datetime",
|
|
224
|
+
],
|
|
170
225
|
}
|
|
171
226
|
|
|
172
227
|
response = await client.search(index=index_name, body=query)
|