sfeos-helpers 6.8.1__py3-none-any.whl → 6.10.0__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.8.1.dist-info → sfeos_helpers-6.10.0.dist-info}/METADATA +2 -2
- {sfeos_helpers-6.8.1.dist-info → sfeos_helpers-6.10.0.dist-info}/RECORD +20 -19
- stac_fastapi/sfeos_helpers/aggregation/client.py +3 -5
- stac_fastapi/sfeos_helpers/database/__init__.py +20 -1
- 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 +93 -41
- stac_fastapi/sfeos_helpers/database/query.py +1 -1
- stac_fastapi/sfeos_helpers/database/utils.py +34 -2
- stac_fastapi/sfeos_helpers/mappings.py +132 -4
- stac_fastapi/sfeos_helpers/models/patch.py +2 -5
- stac_fastapi/sfeos_helpers/search_engine/base.py +30 -0
- stac_fastapi/sfeos_helpers/search_engine/index_operations.py +91 -30
- stac_fastapi/sfeos_helpers/search_engine/inserters.py +173 -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 -14
- stac_fastapi/sfeos_helpers/version.py +1 -1
- {sfeos_helpers-6.8.1.dist-info → sfeos_helpers-6.10.0.dist-info}/WHEEL +0 -0
|
@@ -25,11 +25,135 @@ Function Naming Conventions:
|
|
|
25
25
|
- Parameter names should be consistent across similar functions
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
|
+
import copy
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
28
31
|
import os
|
|
29
|
-
from typing import Any, Dict, Literal, Protocol
|
|
32
|
+
from typing import Any, Dict, Literal, Optional, Protocol, Union
|
|
30
33
|
|
|
31
34
|
from stac_fastapi.core.utilities import get_bool_env
|
|
32
35
|
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def merge_mappings(base: Dict[str, Any], custom: Dict[str, Any]) -> None:
|
|
40
|
+
"""Recursively merge custom mappings into base mappings.
|
|
41
|
+
|
|
42
|
+
Custom mappings will overwrite base mappings if keys collide.
|
|
43
|
+
Nested dictionaries are merged recursively.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
base: The base mapping dictionary to merge into (modified in place).
|
|
47
|
+
custom: The custom mapping dictionary to merge from.
|
|
48
|
+
"""
|
|
49
|
+
for key, value in custom.items():
|
|
50
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
51
|
+
merge_mappings(base[key], value)
|
|
52
|
+
else:
|
|
53
|
+
base[key] = value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_dynamic_mapping_config(
|
|
57
|
+
config_value: Optional[str],
|
|
58
|
+
) -> Union[bool, str]:
|
|
59
|
+
"""Parse the dynamic mapping configuration value.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
config_value: The configuration value from environment variable.
|
|
63
|
+
Can be "true", "false", "strict", or None.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True for "true" (default), False for "false", or the string value
|
|
67
|
+
for other settings like "strict".
|
|
68
|
+
"""
|
|
69
|
+
if config_value is None:
|
|
70
|
+
return True
|
|
71
|
+
config_lower = config_value.lower()
|
|
72
|
+
if config_lower == "true":
|
|
73
|
+
return True
|
|
74
|
+
elif config_lower == "false":
|
|
75
|
+
return False
|
|
76
|
+
else:
|
|
77
|
+
return config_lower
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def apply_custom_mappings(
|
|
81
|
+
mappings: Dict[str, Any], custom_mappings_json: Optional[str]
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Apply custom mappings from a JSON string to the mappings dictionary.
|
|
84
|
+
|
|
85
|
+
The custom mappings JSON should have the same structure as ES_ITEMS_MAPPINGS.
|
|
86
|
+
It will be recursively merged at the root level, allowing users to override
|
|
87
|
+
any part of the mapping including properties, dynamic_templates, etc.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
mappings: The mappings dictionary to modify (modified in place).
|
|
91
|
+
custom_mappings_json: JSON string containing custom mappings.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
Logs error if JSON parsing or merging fails.
|
|
95
|
+
"""
|
|
96
|
+
if not custom_mappings_json:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
custom_mappings = json.loads(custom_mappings_json)
|
|
101
|
+
merge_mappings(mappings, custom_mappings)
|
|
102
|
+
except json.JSONDecodeError as e:
|
|
103
|
+
logger.error(f"Failed to parse STAC_FASTAPI_ES_CUSTOM_MAPPINGS JSON: {e}")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error(f"Failed to merge STAC_FASTAPI_ES_CUSTOM_MAPPINGS: {e}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_items_mappings(
|
|
109
|
+
dynamic_mapping: Optional[str] = None, custom_mappings: Optional[str] = None
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""Get the ES_ITEMS_MAPPINGS with optional dynamic mapping and custom mappings applied.
|
|
112
|
+
|
|
113
|
+
This function creates a fresh copy of the base mappings and applies the
|
|
114
|
+
specified configuration. Useful for testing or programmatic configuration.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
dynamic_mapping: Override for STAC_FASTAPI_ES_DYNAMIC_MAPPING.
|
|
118
|
+
If None, reads from environment variable.
|
|
119
|
+
custom_mappings: Override for STAC_FASTAPI_ES_CUSTOM_MAPPINGS.
|
|
120
|
+
If None, reads from environment variable.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A new dictionary containing the configured mappings.
|
|
124
|
+
"""
|
|
125
|
+
mappings = copy.deepcopy(_BASE_ITEMS_MAPPINGS)
|
|
126
|
+
|
|
127
|
+
# Apply dynamic mapping configuration
|
|
128
|
+
dynamic_config = (
|
|
129
|
+
dynamic_mapping
|
|
130
|
+
if dynamic_mapping is not None
|
|
131
|
+
else os.getenv("STAC_FASTAPI_ES_DYNAMIC_MAPPING", "true")
|
|
132
|
+
)
|
|
133
|
+
mappings["dynamic"] = parse_dynamic_mapping_config(dynamic_config)
|
|
134
|
+
|
|
135
|
+
# Apply custom mappings
|
|
136
|
+
custom_config = (
|
|
137
|
+
custom_mappings
|
|
138
|
+
if custom_mappings is not None
|
|
139
|
+
else os.getenv("STAC_FASTAPI_ES_CUSTOM_MAPPINGS")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if custom_config is None:
|
|
143
|
+
mappings_file = os.getenv("STAC_FASTAPI_ES_MAPPINGS_FILE")
|
|
144
|
+
if mappings_file:
|
|
145
|
+
try:
|
|
146
|
+
with open(mappings_file, "r") as f:
|
|
147
|
+
custom_config = f.read()
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(
|
|
150
|
+
f"Failed to read STAC_FASTAPI_ES_MAPPINGS_FILE at {mappings_file}: {e}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
apply_custom_mappings(mappings, custom_config)
|
|
154
|
+
|
|
155
|
+
return mappings
|
|
156
|
+
|
|
33
157
|
|
|
34
158
|
# stac_pydantic classes extend _GeometryBase, which doesn't have a type field,
|
|
35
159
|
# So create our own Protocol for typing
|
|
@@ -129,7 +253,8 @@ ES_MAPPINGS_DYNAMIC_TEMPLATES = [
|
|
|
129
253
|
},
|
|
130
254
|
]
|
|
131
255
|
|
|
132
|
-
|
|
256
|
+
# Base items mappings without dynamic configuration applied
|
|
257
|
+
_BASE_ITEMS_MAPPINGS = {
|
|
133
258
|
"numeric_detection": False,
|
|
134
259
|
"dynamic_templates": ES_MAPPINGS_DYNAMIC_TEMPLATES,
|
|
135
260
|
"properties": {
|
|
@@ -143,8 +268,8 @@ ES_ITEMS_MAPPINGS = {
|
|
|
143
268
|
"properties": {
|
|
144
269
|
# Common https://github.com/radiantearth/stac-spec/blob/master/item-spec/common-metadata.md
|
|
145
270
|
"datetime": {"type": "date_nanos"},
|
|
146
|
-
"start_datetime": {"type": "
|
|
147
|
-
"end_datetime": {"type": "
|
|
271
|
+
"start_datetime": {"type": "date_nanos"},
|
|
272
|
+
"end_datetime": {"type": "date_nanos"},
|
|
148
273
|
"created": {"type": "date"},
|
|
149
274
|
"updated": {"type": "date"},
|
|
150
275
|
# Satellite Extension https://github.com/stac-extensions/sat
|
|
@@ -155,6 +280,9 @@ ES_ITEMS_MAPPINGS = {
|
|
|
155
280
|
},
|
|
156
281
|
}
|
|
157
282
|
|
|
283
|
+
# ES_ITEMS_MAPPINGS with environment-based configuration applied at module load time
|
|
284
|
+
ES_ITEMS_MAPPINGS = get_items_mappings()
|
|
285
|
+
|
|
158
286
|
ES_COLLECTIONS_MAPPINGS = {
|
|
159
287
|
"numeric_detection": False,
|
|
160
288
|
"dynamic_templates": ES_MAPPINGS_DYNAMIC_TEMPLATES,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import re
|
|
4
4
|
from typing import Any, Dict, Optional, Union
|
|
5
5
|
|
|
6
|
-
from pydantic import BaseModel, model_validator
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
7
7
|
|
|
8
8
|
regex = re.compile(r"([^.' ]*:[^.'[ ]*)\.?")
|
|
9
9
|
replacements = str.maketrans({"/": "", ".": "", ":": "", "[": "", "]": ""})
|
|
@@ -84,10 +84,7 @@ class ElasticPath(BaseModel):
|
|
|
84
84
|
variable_name: Optional[str] = None
|
|
85
85
|
param_key: Optional[str] = None
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
"""Class config."""
|
|
89
|
-
|
|
90
|
-
frozen = True
|
|
87
|
+
model_config = ConfigDict(frozen=True)
|
|
91
88
|
|
|
92
89
|
@model_validator(mode="before")
|
|
93
90
|
@classmethod
|
|
@@ -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
|
|
|
@@ -31,34 +42,65 @@ class IndexOperations:
|
|
|
31
42
|
index_name = f"{index_by_collection_id(collection_id)}-000001"
|
|
32
43
|
alias_name = index_alias_by_collection_id(collection_id)
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
if hasattr(client, "options"):
|
|
46
|
+
await client.options(ignore_status=[400]).indices.create(
|
|
47
|
+
index=index_name,
|
|
48
|
+
body=self._create_index_body({alias_name: {}}),
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
await client.indices.create(
|
|
52
|
+
index=index_name,
|
|
53
|
+
body=self._create_index_body({alias_name: {}}),
|
|
54
|
+
params={"ignore": [400]},
|
|
55
|
+
)
|
|
39
56
|
return index_name
|
|
40
57
|
|
|
41
58
|
async def create_datetime_index(
|
|
42
|
-
self,
|
|
59
|
+
self,
|
|
60
|
+
client: Any,
|
|
61
|
+
collection_id: str,
|
|
62
|
+
start_datetime: str | None,
|
|
63
|
+
datetime: str | None,
|
|
64
|
+
end_datetime: str | None,
|
|
43
65
|
) -> str:
|
|
44
66
|
"""Create a datetime-based index for the given collection.
|
|
45
67
|
|
|
46
68
|
Args:
|
|
47
69
|
client: Search engine client instance.
|
|
48
70
|
collection_id (str): Collection identifier.
|
|
49
|
-
|
|
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.
|
|
50
74
|
|
|
51
75
|
Returns:
|
|
52
|
-
str: Created
|
|
76
|
+
str: Created datetime alias name.
|
|
53
77
|
"""
|
|
54
78
|
index_name = self.create_index_name(collection_id)
|
|
55
|
-
alias_name = self.create_alias_name(collection_id, start_date)
|
|
56
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
|
+
|
|
57
99
|
await client.indices.create(
|
|
58
100
|
index=index_name,
|
|
59
|
-
body=self._create_index_body(
|
|
101
|
+
body=self._create_index_body(aliases),
|
|
60
102
|
)
|
|
61
|
-
return
|
|
103
|
+
return created_alias
|
|
62
104
|
|
|
63
105
|
@staticmethod
|
|
64
106
|
async def update_index_alias(client: Any, end_date: str, old_alias: str) -> str:
|
|
@@ -84,23 +126,33 @@ class IndexOperations:
|
|
|
84
126
|
return new_alias
|
|
85
127
|
|
|
86
128
|
@staticmethod
|
|
87
|
-
async def change_alias_name(
|
|
88
|
-
|
|
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.
|
|
89
136
|
|
|
90
137
|
Args:
|
|
91
138
|
client: Search engine client instance.
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
94
142
|
|
|
95
143
|
Returns:
|
|
96
144
|
None
|
|
97
145
|
"""
|
|
98
|
-
aliases_info = await client.indices.get_alias(name=
|
|
99
|
-
|
|
146
|
+
aliases_info = await client.indices.get_alias(name=old_start_datetime_alias)
|
|
147
|
+
index_name = list(aliases_info.keys())[0]
|
|
100
148
|
|
|
101
|
-
|
|
149
|
+
actions = []
|
|
150
|
+
for old_alias in aliases_to_change:
|
|
102
151
|
actions.append({"remove": {"index": index_name, "alias": old_alias}})
|
|
152
|
+
|
|
153
|
+
for new_alias in aliases_to_create:
|
|
103
154
|
actions.append({"add": {"index": index_name, "alias": new_alias}})
|
|
155
|
+
|
|
104
156
|
await client.indices.update_aliases(body={"actions": actions})
|
|
105
157
|
|
|
106
158
|
@staticmethod
|
|
@@ -117,18 +169,23 @@ class IndexOperations:
|
|
|
117
169
|
return f"{ITEMS_INDEX_PREFIX}{cleaned.lower()}_{uuid.uuid4()}"
|
|
118
170
|
|
|
119
171
|
@staticmethod
|
|
120
|
-
def create_alias_name(
|
|
121
|
-
|
|
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.
|
|
122
178
|
|
|
123
179
|
Args:
|
|
124
180
|
collection_id (str): Collection identifier.
|
|
125
|
-
|
|
181
|
+
name (Literal["start_datetime", "datetime", "end_datetime"]): Type of alias to create.
|
|
182
|
+
start_date (str): Date value for the alias.
|
|
126
183
|
|
|
127
184
|
Returns:
|
|
128
|
-
str:
|
|
185
|
+
str: Formatted alias name with prefix, type, collection ID, and date.
|
|
129
186
|
"""
|
|
130
187
|
cleaned = collection_id.translate(_ES_INDEX_NAME_UNSUPPORTED_CHARS_TABLE)
|
|
131
|
-
return f"{ITEMS_INDEX_PREFIX}{cleaned.lower()}_{start_date}"
|
|
188
|
+
return f"{ITEMS_INDEX_PREFIX}{name}_{cleaned.lower()}_{start_date}"
|
|
132
189
|
|
|
133
190
|
@staticmethod
|
|
134
191
|
def _create_index_body(aliases: Dict[str, Dict]) -> Dict[str, Any]:
|
|
@@ -146,21 +203,25 @@ class IndexOperations:
|
|
|
146
203
|
"settings": ES_ITEMS_SETTINGS,
|
|
147
204
|
}
|
|
148
205
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
152
210
|
|
|
153
211
|
Args:
|
|
154
212
|
client: Search engine client instance.
|
|
155
213
|
index_name (str): Name of the index to query.
|
|
156
214
|
|
|
157
215
|
Returns:
|
|
158
|
-
|
|
216
|
+
dict[str, Any]: Latest item document from the index with metadata.
|
|
159
217
|
"""
|
|
160
218
|
query = {
|
|
161
219
|
"size": 1,
|
|
162
|
-
"sort": [{"properties.
|
|
163
|
-
"_source": [
|
|
220
|
+
"sort": [{f"properties.{self.primary_datetime_name}": {"order": "desc"}}],
|
|
221
|
+
"_source": [
|
|
222
|
+
"properties.start_datetime",
|
|
223
|
+
"properties.datetime",
|
|
224
|
+
],
|
|
164
225
|
}
|
|
165
226
|
|
|
166
227
|
response = await client.search(index=index_name, body=query)
|