stac-fastapi-elasticsearch 6.9.0__tar.gz → 6.10.1__tar.gz

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.
@@ -141,3 +141,14 @@ venv
141
141
  /docs/src/api/*
142
142
 
143
143
  .DS_Store
144
+
145
+ # Helm
146
+ *.tgz
147
+ charts/*/charts/
148
+ charts/*/requirements.lock
149
+ charts/*/Chart.lock
150
+ helm-chart/stac-fastapi/charts/
151
+ helm-chart/stac-fastapi/Chart.lock
152
+ helm-chart/stac-fastapi/*.tgz
153
+ helm-chart/test-results/
154
+ helm-chart/tmp/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stac_fastapi_elasticsearch
3
- Version: 6.9.0
3
+ Version: 6.10.1
4
4
  Summary: An implementation of STAC API based on the FastAPI framework with Elasticsearch.
5
5
  Project-URL: Homepage, https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -15,8 +15,8 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Python: >=3.11
17
17
  Requires-Dist: elasticsearch[async]~=8.19.1
18
- Requires-Dist: sfeos-helpers==6.9.0
19
- Requires-Dist: stac-fastapi-core==6.9.0
18
+ Requires-Dist: sfeos-helpers==6.10.1
19
+ Requires-Dist: stac-fastapi-core==6.10.1
20
20
  Requires-Dist: starlette<0.36.0,>=0.35.0
21
21
  Requires-Dist: uvicorn~=0.23.0
22
22
  Provides-Extra: dev
@@ -28,7 +28,7 @@ Requires-Dist: pytest-cov~=4.0.0; extra == 'dev'
28
28
  Requires-Dist: pytest~=8.0; extra == 'dev'
29
29
  Requires-Dist: redis~=6.4.0; extra == 'dev'
30
30
  Requires-Dist: retry~=0.9.2; extra == 'dev'
31
- Requires-Dist: stac-fastapi-core[redis]==6.9.0; extra == 'dev'
31
+ Requires-Dist: stac-fastapi-core[redis]==6.10.1; extra == 'dev'
32
32
  Provides-Extra: docs
33
33
  Requires-Dist: mkdocs-material~=9.0.0; extra == 'docs'
34
34
  Requires-Dist: mkdocs~=1.4.0; extra == 'docs'
@@ -36,7 +36,7 @@ Requires-Dist: pdocs~=1.2.0; extra == 'docs'
36
36
  Requires-Dist: redis~=6.4.0; extra == 'docs'
37
37
  Requires-Dist: retry~=0.9.2; extra == 'docs'
38
38
  Provides-Extra: redis
39
- Requires-Dist: stac-fastapi-core[redis]==6.9.0; extra == 'redis'
39
+ Requires-Dist: stac-fastapi-core[redis]==6.10.1; extra == 'redis'
40
40
  Provides-Extra: server
41
41
  Requires-Dist: uvicorn[standard]~=0.23.0; extra == 'server'
42
42
  Description-Content-Type: text/markdown
@@ -28,8 +28,8 @@ keywords = [
28
28
  ]
29
29
  dynamic = ["version"]
30
30
  dependencies = [
31
- "stac-fastapi-core==6.9.0",
32
- "sfeos-helpers==6.9.0",
31
+ "stac-fastapi-core==6.10.1",
32
+ "sfeos-helpers==6.10.1",
33
33
  "elasticsearch[async]~=8.19.1",
34
34
  "uvicorn~=0.23.0",
35
35
  "starlette>=0.35.0,<0.36.0",
@@ -48,7 +48,7 @@ dev = [
48
48
  "httpx>=0.24.0,<0.28.0",
49
49
  "redis~=6.4.0",
50
50
  "retry~=0.9.2",
51
- "stac-fastapi-core[redis]==6.9.0",
51
+ "stac-fastapi-core[redis]==6.10.1",
52
52
  ]
53
53
  docs = [
54
54
  "mkdocs~=1.4.0",
@@ -58,7 +58,7 @@ docs = [
58
58
  "retry~=0.9.2",
59
59
  ]
60
60
  redis = [
61
- "stac-fastapi-core[redis]==6.9.0",
61
+ "stac-fastapi-core[redis]==6.10.1",
62
62
  ]
63
63
  server = [
64
64
  "uvicorn[standard]~=0.23.0",
@@ -215,7 +215,7 @@ if ENABLE_CATALOGS_ROUTE:
215
215
  ),
216
216
  settings=settings,
217
217
  conformance_classes=[
218
- "https://api.stacspec.org/v1.0.0-beta.1/catalogs-endpoint",
218
+ "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs",
219
219
  ],
220
220
  )
221
221
  extensions.append(catalogs_extension)
@@ -244,7 +244,7 @@ items_get_request_model = create_request_model(
244
244
  app_config = {
245
245
  "title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-elasticsearch"),
246
246
  "description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-elasticsearch"),
247
- "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.9.0"),
247
+ "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.10.1"),
248
248
  "settings": settings,
249
249
  "extensions": extensions,
250
250
  "client": CoreClient(
@@ -33,11 +33,14 @@ from stac_fastapi.extensions.core.transaction.request import (
33
33
  PatchOperation,
34
34
  )
35
35
  from stac_fastapi.sfeos_helpers.database import (
36
+ ItemAlreadyExistsError,
36
37
  add_bbox_shape_to_collection,
37
38
  apply_collections_bbox_filter_shared,
38
39
  apply_collections_datetime_filter_shared,
39
40
  apply_free_text_filter_shared,
40
41
  apply_intersects_filter_shared,
42
+ check_item_exists_in_alias,
43
+ check_item_exists_in_alias_sync,
41
44
  create_index_templates_shared,
42
45
  delete_item_index_shared,
43
46
  get_queryables_mapping_shared,
@@ -53,6 +56,7 @@ from stac_fastapi.sfeos_helpers.database.query import (
53
56
  add_collections_to_body,
54
57
  )
55
58
  from stac_fastapi.sfeos_helpers.database.utils import (
59
+ add_hidden_filter,
56
60
  merge_to_operations,
57
61
  operations_to_script,
58
62
  )
@@ -67,6 +71,7 @@ from stac_fastapi.sfeos_helpers.mappings import (
67
71
  from stac_fastapi.sfeos_helpers.search_engine import (
68
72
  BaseIndexInserter,
69
73
  BaseIndexSelector,
74
+ DatetimeIndexInserter,
70
75
  IndexInsertionFactory,
71
76
  IndexSelectorFactory,
72
77
  )
@@ -406,12 +411,22 @@ class DatabaseLogic(BaseDatabaseLogic):
406
411
  Notes:
407
412
  The Item is retrieved from the Elasticsearch database using the `client.get` method,
408
413
  with the index for the Collection as the target index and the combined `mk_item_id` as the document id.
414
+ Item is hidden if hide_item_path is configured via env var.
409
415
  """
410
416
  try:
417
+ base_query = {"term": {"_id": mk_item_id(item_id, collection_id)}}
418
+
419
+ HIDE_ITEM_PATH = os.getenv("HIDE_ITEM_PATH", None)
420
+
421
+ if HIDE_ITEM_PATH:
422
+ query = add_hidden_filter(base_query, HIDE_ITEM_PATH)
423
+ else:
424
+ query = base_query
425
+
411
426
  response = await self.client.search(
412
427
  index=index_alias_by_collection_id(collection_id),
413
428
  body={
414
- "query": {"term": {"_id": mk_item_id(item_id, collection_id)}},
429
+ "query": query,
415
430
  "size": 1,
416
431
  },
417
432
  )
@@ -656,7 +671,7 @@ class DatabaseLogic(BaseDatabaseLogic):
656
671
  ),
657
672
  ],
658
673
  )
659
- return search.query(filter_query), datetime_search
674
+ return search.query(filter_query), datetime_search
660
675
 
661
676
  @staticmethod
662
677
  def apply_bbox_filter(search: Search, bbox: List):
@@ -811,7 +826,7 @@ class DatabaseLogic(BaseDatabaseLogic):
811
826
  token: Optional[str],
812
827
  sort: Optional[Dict[str, Dict[str, str]]],
813
828
  collection_ids: Optional[List[str]],
814
- datetime_search: Dict[str, Optional[str]],
829
+ datetime_search: str,
815
830
  ignore_unavailable: bool = True,
816
831
  ) -> Tuple[Iterable[Dict[str, Any]], Optional[int], Optional[str]]:
817
832
  """Execute a search query with limit and other optional parameters.
@@ -822,7 +837,7 @@ class DatabaseLogic(BaseDatabaseLogic):
822
837
  token (Optional[str]): The token used to return the next set of results.
823
838
  sort (Optional[Dict[str, Dict[str, str]]]): Specifies how the results should be sorted.
824
839
  collection_ids (Optional[List[str]]): The collection ids to search.
825
- datetime_search (Dict[str, Optional[str]]): Datetime range used for index selection.
840
+ datetime_search (str): Datetime used for index selection.
826
841
  ignore_unavailable (bool, optional): Whether to ignore unavailable collections. Defaults to True.
827
842
 
828
843
  Returns:
@@ -854,6 +869,10 @@ class DatabaseLogic(BaseDatabaseLogic):
854
869
 
855
870
  size_limit = min(limit + 1, max_result_window)
856
871
 
872
+ HIDE_ITEM_PATH = os.getenv("HIDE_ITEM_PATH", None)
873
+ if HIDE_ITEM_PATH:
874
+ query = add_hidden_filter(query, HIDE_ITEM_PATH)
875
+
857
876
  search_task = asyncio.create_task(
858
877
  self.client.search(
859
878
  index=index_param,
@@ -865,11 +884,17 @@ class DatabaseLogic(BaseDatabaseLogic):
865
884
  )
866
885
  )
867
886
 
887
+ # Apply hidden filter to count query as well
888
+ count_query = search.to_dict(count=True)
889
+ if HIDE_ITEM_PATH:
890
+ q = count_query.get("query")
891
+ count_query["query"] = add_hidden_filter(q, HIDE_ITEM_PATH)
892
+
868
893
  count_task = asyncio.create_task(
869
894
  self.client.count(
870
895
  index=index_param,
871
896
  ignore_unavailable=ignore_unavailable,
872
- body=search.to_dict(count=True),
897
+ body=count_query,
873
898
  )
874
899
  )
875
900
 
@@ -912,7 +937,7 @@ class DatabaseLogic(BaseDatabaseLogic):
912
937
  geometry_geohash_grid_precision: int,
913
938
  geometry_geotile_grid_precision: int,
914
939
  datetime_frequency_interval: str,
915
- datetime_search,
940
+ datetime_search: str,
916
941
  ignore_unavailable: Optional[bool] = True,
917
942
  ):
918
943
  """Return aggregations of STAC Items."""
@@ -974,6 +999,44 @@ class DatabaseLogic(BaseDatabaseLogic):
974
999
  if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id):
975
1000
  raise NotFoundError(f"Collection {collection_id} does not exist")
976
1001
 
1002
+ async def _check_item_exists_in_collection(
1003
+ self, collection_id: str, item_id: str
1004
+ ) -> bool:
1005
+ """Check if an item exists across all indexes for a collection.
1006
+
1007
+ Args:
1008
+ collection_id (str): The collection identifier.
1009
+ item_id (str): The item identifier.
1010
+
1011
+ Returns:
1012
+ bool: True if the item exists in any index, False otherwise.
1013
+ """
1014
+ alias = index_alias_by_collection_id(collection_id)
1015
+ doc_id = mk_item_id(item_id, collection_id)
1016
+ try:
1017
+ return await check_item_exists_in_alias(self.client, alias, doc_id)
1018
+ except Exception:
1019
+ return False
1020
+
1021
+ def _check_item_exists_in_collection_sync(
1022
+ self, collection_id: str, item_id: str
1023
+ ) -> bool:
1024
+ """Check if an item exists across all indexes for a collection (sync version).
1025
+
1026
+ Args:
1027
+ collection_id (str): The collection identifier.
1028
+ item_id (str): The item identifier.
1029
+
1030
+ Returns:
1031
+ bool: True if the item exists in any index, False otherwise.
1032
+ """
1033
+ alias = index_alias_by_collection_id(collection_id)
1034
+ doc_id = mk_item_id(item_id, collection_id)
1035
+ try:
1036
+ return check_item_exists_in_alias_sync(self.sync_client, alias, doc_id)
1037
+ except Exception:
1038
+ return False
1039
+
977
1040
  async def async_prep_create_item(
978
1041
  self, item: Item, base_url: str, exist_ok: bool = False
979
1042
  ) -> Item:
@@ -989,31 +1052,21 @@ class DatabaseLogic(BaseDatabaseLogic):
989
1052
  Item: The prepped item.
990
1053
 
991
1054
  Raises:
992
- ConflictError: If the item already exists in the database.
1055
+ ItemAlreadyExistsError: If the item already exists in the database.
993
1056
 
994
1057
  """
995
1058
  await self.check_collection_exists(collection_id=item["collection"])
996
- alias = index_alias_by_collection_id(item["collection"])
997
- doc_id = mk_item_id(item["id"], item["collection"])
998
-
999
- if not exist_ok:
1000
- alias_exists = await self.client.indices.exists_alias(name=alias)
1001
1059
 
1002
- if alias_exists:
1003
- alias_info = await self.client.indices.get_alias(name=alias)
1004
- indices = list(alias_info.keys())
1005
-
1006
- for index in indices:
1007
- if await self.client.exists(index=index, id=doc_id):
1008
- raise ConflictError(
1009
- f"Item {item['id']} in collection {item['collection']} already exists"
1010
- )
1060
+ if not exist_ok and await self._check_item_exists_in_collection(
1061
+ item["collection"], item["id"]
1062
+ ):
1063
+ raise ItemAlreadyExistsError(item["id"], item["collection"])
1011
1064
 
1012
1065
  return self.item_serializer.stac_to_db(item, base_url)
1013
1066
 
1014
1067
  async def bulk_async_prep_create_item(
1015
1068
  self, item: Item, base_url: str, exist_ok: bool = False
1016
- ) -> Item:
1069
+ ) -> Optional[Item]:
1017
1070
  """
1018
1071
  Prepare an item for insertion into the database.
1019
1072
 
@@ -1041,20 +1094,18 @@ class DatabaseLogic(BaseDatabaseLogic):
1041
1094
  # Check if the collection exists
1042
1095
  await self.check_collection_exists(collection_id=item["collection"])
1043
1096
 
1044
- # Check if the item already exists in the database
1045
- if not exist_ok and await self.client.exists(
1046
- index=index_alias_by_collection_id(item["collection"]),
1047
- id=mk_item_id(item["id"], item["collection"]),
1097
+ # Check if the item already exists in the database (across all datetime indexes)
1098
+ if not exist_ok and await self._check_item_exists_in_collection(
1099
+ item["collection"], item["id"]
1048
1100
  ):
1049
- error_message = (
1050
- f"Item {item['id']} in collection {item['collection']} already exists."
1051
- )
1052
1101
  if self.async_settings.raise_on_bulk_error:
1053
- raise ConflictError(error_message)
1102
+ raise ItemAlreadyExistsError(item["id"], item["collection"])
1054
1103
  else:
1055
1104
  logger.warning(
1056
- f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false."
1105
+ f"Item {item['id']} in collection {item['collection']} already exists. "
1106
+ "Skipping as `RAISE_ON_BULK_ERROR` is set to false."
1057
1107
  )
1108
+ return None
1058
1109
 
1059
1110
  # Serialize the item into a database-compatible format
1060
1111
  prepped_item = self.item_serializer.stac_to_db(item, base_url)
@@ -1063,7 +1114,7 @@ class DatabaseLogic(BaseDatabaseLogic):
1063
1114
 
1064
1115
  def bulk_sync_prep_create_item(
1065
1116
  self, item: Item, base_url: str, exist_ok: bool = False
1066
- ) -> Item:
1117
+ ) -> Optional[Item]:
1067
1118
  """
1068
1119
  Prepare an item for insertion into the database.
1069
1120
 
@@ -1092,20 +1143,18 @@ class DatabaseLogic(BaseDatabaseLogic):
1092
1143
  if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=item["collection"]):
1093
1144
  raise NotFoundError(f"Collection {item['collection']} does not exist")
1094
1145
 
1095
- # Check if the item already exists in the database
1096
- if not exist_ok and self.sync_client.exists(
1097
- index=index_alias_by_collection_id(item["collection"]),
1098
- id=mk_item_id(item["id"], item["collection"]),
1146
+ # Check if the item already exists in the database (across all datetime indexes)
1147
+ if not exist_ok and self._check_item_exists_in_collection_sync(
1148
+ item["collection"], item["id"]
1099
1149
  ):
1100
- error_message = (
1101
- f"Item {item['id']} in collection {item['collection']} already exists."
1102
- )
1103
1150
  if self.sync_settings.raise_on_bulk_error:
1104
- raise ConflictError(error_message)
1151
+ raise ItemAlreadyExistsError(item["id"], item["collection"])
1105
1152
  else:
1106
1153
  logger.warning(
1107
- f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false."
1154
+ f"Item {item['id']} in collection {item['collection']} already exists. "
1155
+ "Skipping as `RAISE_ON_BULK_ERROR` is set to false."
1108
1156
  )
1157
+ return None
1109
1158
 
1110
1159
  # Serialize the item into a database-compatible format
1111
1160
  prepped_item = self.item_serializer.stac_to_db(item, base_url)
@@ -1150,6 +1199,31 @@ class DatabaseLogic(BaseDatabaseLogic):
1150
1199
  f"Creating item {item_id} in collection {collection_id} with refresh={refresh}"
1151
1200
  )
1152
1201
 
1202
+ if exist_ok and isinstance(self.async_index_inserter, DatetimeIndexInserter):
1203
+ existing_item = await self.get_one_item(collection_id, item_id)
1204
+ primary_datetime_name = self.async_index_inserter.primary_datetime_name
1205
+
1206
+ existing_primary_datetime = existing_item.get("properties", {}).get(
1207
+ primary_datetime_name
1208
+ )
1209
+ new_primary_datetime = item.get("properties", {}).get(primary_datetime_name)
1210
+
1211
+ if existing_primary_datetime != new_primary_datetime:
1212
+ self.async_index_inserter.validate_datetime_field_update(
1213
+ f"properties/{primary_datetime_name}"
1214
+ )
1215
+
1216
+ if primary_datetime_name == "start_datetime":
1217
+ existing_end_datetime = existing_item.get("properties", {}).get(
1218
+ "end_datetime"
1219
+ )
1220
+ new_end_datetime = item.get("properties", {}).get("end_datetime")
1221
+
1222
+ if existing_end_datetime != new_end_datetime:
1223
+ self.async_index_inserter.validate_datetime_field_update(
1224
+ "properties/end_datetime"
1225
+ )
1226
+
1153
1227
  # Prepare the item for insertion
1154
1228
  item = await self.async_prep_create_item(
1155
1229
  item=item, base_url=base_url, exist_ok=exist_ok
@@ -1218,6 +1292,10 @@ class DatabaseLogic(BaseDatabaseLogic):
1218
1292
  Returns:
1219
1293
  patched item.
1220
1294
  """
1295
+ for operation in operations:
1296
+ if operation.op in ["add", "replace", "remove"]:
1297
+ self.async_index_inserter.validate_datetime_field_update(operation.path)
1298
+
1221
1299
  new_item_id = None
1222
1300
  new_collection_id = None
1223
1301
  script_operations = []
@@ -1238,8 +1316,6 @@ class DatabaseLogic(BaseDatabaseLogic):
1238
1316
  else:
1239
1317
  script_operations.append(operation)
1240
1318
 
1241
- script = operations_to_script(script_operations, create_nest=create_nest)
1242
-
1243
1319
  try:
1244
1320
  search_response = await self.client.search(
1245
1321
  index=index_alias_by_collection_id(collection_id),
@@ -1252,13 +1328,18 @@ class DatabaseLogic(BaseDatabaseLogic):
1252
1328
  raise NotFoundError(
1253
1329
  f"Item {item_id} does not exist inside Collection {collection_id}"
1254
1330
  )
1255
- document_index = search_response["hits"]["hits"][0]["_index"]
1256
- await self.client.update(
1257
- index=document_index,
1258
- id=mk_item_id(item_id, collection_id),
1259
- script=script,
1260
- refresh=True,
1261
- )
1331
+
1332
+ if script_operations:
1333
+ script = operations_to_script(
1334
+ script_operations, create_nest=create_nest
1335
+ )
1336
+ document_index = search_response["hits"]["hits"][0]["_index"]
1337
+ await self.client.update(
1338
+ index=document_index,
1339
+ id=mk_item_id(item_id, collection_id),
1340
+ script=script,
1341
+ refresh=True,
1342
+ )
1262
1343
  except ESNotFoundError:
1263
1344
  raise NotFoundError(
1264
1345
  f"Item {item_id} does not exist inside Collection {collection_id}"
@@ -1271,26 +1352,9 @@ class DatabaseLogic(BaseDatabaseLogic):
1271
1352
  item = await self.get_one_item(collection_id, item_id)
1272
1353
 
1273
1354
  if new_collection_id:
1274
- await self.client.reindex(
1275
- body={
1276
- "dest": {
1277
- "index": f"{ITEMS_INDEX_PREFIX}{new_collection_id}"
1278
- }, # # noqa
1279
- "source": {
1280
- "index": f"{ITEMS_INDEX_PREFIX}{collection_id}",
1281
- "query": {"term": {"id": {"value": item_id}}},
1282
- },
1283
- "script": {
1284
- "lang": "painless",
1285
- "source": (
1286
- f"""ctx._id = ctx._id.replace('{collection_id}', '{new_collection_id}');""" # noqa
1287
- f"""ctx._source.collection = '{new_collection_id}';""" # noqa
1288
- ),
1289
- },
1290
- },
1291
- wait_for_completion=True,
1292
- refresh=True,
1293
- )
1355
+ item["collection"] = new_collection_id
1356
+ item = await self.async_prep_create_item(item=item, base_url=base_url)
1357
+ await self.create_item(item=item, refresh=True)
1294
1358
 
1295
1359
  await self.delete_item(
1296
1360
  item_id=item_id,
@@ -1298,7 +1362,6 @@ class DatabaseLogic(BaseDatabaseLogic):
1298
1362
  refresh=refresh,
1299
1363
  )
1300
1364
 
1301
- item["collection"] = new_collection_id
1302
1365
  collection_id = new_collection_id
1303
1366
 
1304
1367
  if new_item_id:
@@ -1684,6 +1747,7 @@ class DatabaseLogic(BaseDatabaseLogic):
1684
1747
  index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh
1685
1748
  )
1686
1749
  await delete_item_index(collection_id)
1750
+ await self.async_index_inserter.refresh_cache()
1687
1751
 
1688
1752
  async def bulk_async(
1689
1753
  self,
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "6.9.0"
2
+ __version__ = "6.10.1"