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.
@@ -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
- source = "".join(commands)
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": "date"},
272
- "end_datetime": {"type": "date"},
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, 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,
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
- 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.
56
74
 
57
75
  Returns:
58
- str: Created index alias name.
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({collection_alias: {}, alias_name: {}}),
101
+ body=self._create_index_body(aliases),
66
102
  )
67
- return alias_name
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(client: Any, old_alias: str, new_alias: str) -> None:
94
- """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.
95
136
 
96
137
  Args:
97
138
  client: Search engine client instance.
98
- old_alias (str): Current alias name.
99
- 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.
100
142
 
101
143
  Returns:
102
144
  None
103
145
  """
104
- aliases_info = await client.indices.get_alias(name=old_alias)
105
- actions = []
146
+ aliases_info = await client.indices.get_alias(name=old_start_datetime_alias)
147
+ index_name = list(aliases_info.keys())[0]
106
148
 
107
- for index_name in aliases_info.keys():
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(collection_id: str, start_date: str) -> str:
127
- """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.
128
178
 
129
179
  Args:
130
180
  collection_id (str): Collection identifier.
131
- 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.
132
183
 
133
184
  Returns:
134
- str: Alias name with initial date.
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
- @staticmethod
156
- async def find_latest_item_in_index(client: Any, index_name: str) -> dict[str, Any]:
157
- """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.
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
- datetime: Date of the latest item in the index.
216
+ dict[str, Any]: Latest item document from the index with metadata.
165
217
  """
166
218
  query = {
167
219
  "size": 1,
168
- "sort": [{"properties.datetime": {"order": "desc"}}],
169
- "_source": ["properties.datetime"],
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)