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.
@@ -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
- ES_ITEMS_MAPPINGS = {
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": "date"},
147
- "end_datetime": {"type": "date"},
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
- class Config:
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
- await client.indices.create(
35
- index=index_name,
36
- body=self._create_index_body({alias_name: {}}),
37
- params={"ignore": [400]},
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, client: Any, collection_id: str, start_date: str
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
- start_date (str): Start date for the alias.
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 index alias name.
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({collection_alias: {}, alias_name: {}}),
101
+ body=self._create_index_body(aliases),
60
102
  )
61
- return alias_name
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(client: Any, old_alias: str, new_alias: str) -> None:
88
- """Change alias name from old to new.
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
- old_alias (str): Current alias name.
93
- new_alias (str): New alias name.
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=old_alias)
99
- actions = []
146
+ aliases_info = await client.indices.get_alias(name=old_start_datetime_alias)
147
+ index_name = list(aliases_info.keys())[0]
100
148
 
101
- for index_name in aliases_info.keys():
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(collection_id: str, start_date: str) -> str:
121
- """Create index name from collection ID and uuid4.
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
- start_date (str): Start date for the alias.
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: Alias name with initial date.
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
- @staticmethod
150
- async def find_latest_item_in_index(client: Any, index_name: str) -> dict[str, Any]:
151
- """Find the latest item date in the specified index.
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
- datetime: Date of the latest item in the index.
216
+ dict[str, Any]: Latest item document from the index with metadata.
159
217
  """
160
218
  query = {
161
219
  "size": 1,
162
- "sort": [{"properties.datetime": {"order": "desc"}}],
163
- "_source": ["properties.datetime"],
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)