stac-fastapi-opensearch 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_opensearch
3
- Version: 6.9.0
3
+ Version: 6.10.1
4
4
  Summary: An implementation of STAC API based on the FastAPI framework with Opensearch.
5
5
  Project-URL: Homepage, https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -16,8 +16,8 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Python: >=3.11
17
17
  Requires-Dist: opensearch-py[async]~=2.8.0
18
18
  Requires-Dist: opensearch-py~=2.8.0
19
- Requires-Dist: sfeos-helpers==6.9.0
20
- Requires-Dist: stac-fastapi-core==6.9.0
19
+ Requires-Dist: sfeos-helpers==6.10.1
20
+ Requires-Dist: stac-fastapi-core==6.10.1
21
21
  Requires-Dist: starlette<0.36.0,>=0.35.0
22
22
  Requires-Dist: uvicorn~=0.23.0
23
23
  Provides-Extra: dev
@@ -29,13 +29,13 @@ Requires-Dist: pytest-cov~=4.0.0; extra == 'dev'
29
29
  Requires-Dist: pytest~=8.0; extra == 'dev'
30
30
  Requires-Dist: redis~=6.4.0; extra == 'dev'
31
31
  Requires-Dist: retry~=0.9.2; extra == 'dev'
32
- Requires-Dist: stac-fastapi-core[redis]==6.9.0; extra == 'dev'
32
+ Requires-Dist: stac-fastapi-core[redis]==6.10.1; extra == 'dev'
33
33
  Provides-Extra: docs
34
34
  Requires-Dist: mkdocs-material~=9.0.0; extra == 'docs'
35
35
  Requires-Dist: mkdocs~=1.4.0; extra == 'docs'
36
36
  Requires-Dist: pdocs~=1.2.0; extra == 'docs'
37
37
  Provides-Extra: redis
38
- Requires-Dist: stac-fastapi-core[redis]==6.9.0; extra == 'redis'
38
+ Requires-Dist: stac-fastapi-core[redis]==6.10.1; extra == 'redis'
39
39
  Provides-Extra: server
40
40
  Requires-Dist: uvicorn[standard]~=0.23.0; extra == 'server'
41
41
  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
  "opensearch-py~=2.8.0",
34
34
  "opensearch-py[async]~=2.8.0",
35
35
  "uvicorn~=0.23.0",
@@ -49,7 +49,7 @@ dev = [
49
49
  "httpx>=0.24.0,<0.28.0",
50
50
  "redis~=6.4.0",
51
51
  "retry~=0.9.2",
52
- "stac-fastapi-core[redis]==6.9.0",
52
+ "stac-fastapi-core[redis]==6.10.1",
53
53
  ]
54
54
  docs = [
55
55
  "mkdocs~=1.4.0",
@@ -57,7 +57,7 @@ docs = [
57
57
  "pdocs~=1.2.0",
58
58
  ]
59
59
  redis = [
60
- "stac-fastapi-core[redis]==6.9.0",
60
+ "stac-fastapi-core[redis]==6.10.1",
61
61
  ]
62
62
  server = [
63
63
  "uvicorn[standard]~=0.23.0",
@@ -214,7 +214,7 @@ if ENABLE_CATALOGS_ROUTE:
214
214
  ),
215
215
  settings=settings,
216
216
  conformance_classes=[
217
- "https://api.stacspec.org/v1.0.0-beta.1/catalogs-endpoint",
217
+ "https://api.stacspec.org/v1.0.0-beta.1/multi-tenant-catalogs",
218
218
  ],
219
219
  )
220
220
  extensions.append(catalogs_extension)
@@ -243,7 +243,7 @@ items_get_request_model = create_request_model(
243
243
  app_config = {
244
244
  "title": os.getenv("STAC_FASTAPI_TITLE", "stac-fastapi-opensearch"),
245
245
  "description": os.getenv("STAC_FASTAPI_DESCRIPTION", "stac-fastapi-opensearch"),
246
- "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.9.0"),
246
+ "api_version": os.getenv("STAC_FASTAPI_VERSION", "6.10.1"),
247
247
  "settings": settings,
248
248
  "extensions": extensions,
249
249
  "client": CoreClient(
@@ -29,11 +29,14 @@ from stac_fastapi.opensearch.config import (
29
29
  )
30
30
  from stac_fastapi.opensearch.config import OpensearchSettings as SyncSearchSettings
31
31
  from stac_fastapi.sfeos_helpers.database import (
32
+ ItemAlreadyExistsError,
32
33
  add_bbox_shape_to_collection,
33
34
  apply_collections_bbox_filter_shared,
34
35
  apply_collections_datetime_filter_shared,
35
36
  apply_free_text_filter_shared,
36
37
  apply_intersects_filter_shared,
38
+ check_item_exists_in_alias,
39
+ check_item_exists_in_alias_sync,
37
40
  create_index_templates_shared,
38
41
  delete_item_index_shared,
39
42
  get_queryables_mapping_shared,
@@ -49,6 +52,7 @@ from stac_fastapi.sfeos_helpers.database.query import (
49
52
  add_collections_to_body,
50
53
  )
51
54
  from stac_fastapi.sfeos_helpers.database.utils import (
55
+ add_hidden_filter,
52
56
  merge_to_operations,
53
57
  operations_to_script,
54
58
  )
@@ -64,6 +68,7 @@ from stac_fastapi.sfeos_helpers.mappings import (
64
68
  from stac_fastapi.sfeos_helpers.search_engine import (
65
69
  BaseIndexInserter,
66
70
  BaseIndexSelector,
71
+ DatetimeIndexInserter,
67
72
  IndexInsertionFactory,
68
73
  IndexSelectorFactory,
69
74
  )
@@ -406,12 +411,22 @@ class DatabaseLogic(BaseDatabaseLogic):
406
411
  Notes:
407
412
  The Item is retrieved from the Opensearch 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
  )
@@ -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:
@@ -846,7 +861,11 @@ class DatabaseLogic(BaseDatabaseLogic):
846
861
  index_param = ITEM_INDICES
847
862
  query = add_collections_to_body(collection_ids, query)
848
863
 
849
- if query:
864
+ HIDE_ITEM_PATH = os.getenv("HIDE_ITEM_PATH", None)
865
+
866
+ if HIDE_ITEM_PATH:
867
+ search_body["query"] = add_hidden_filter(query, HIDE_ITEM_PATH)
868
+ elif query:
850
869
  search_body["query"] = query
851
870
 
852
871
  search_after = None
@@ -871,11 +890,17 @@ class DatabaseLogic(BaseDatabaseLogic):
871
890
  )
872
891
  )
873
892
 
893
+ # Ensure hidden item is not counted
894
+ count_query = search.to_dict(count=True)
895
+ if HIDE_ITEM_PATH:
896
+ q = count_query.get("query")
897
+ count_query["query"] = add_hidden_filter(q, HIDE_ITEM_PATH)
898
+
874
899
  count_task = asyncio.create_task(
875
900
  self.client.count(
876
901
  index=index_param,
877
902
  ignore_unavailable=ignore_unavailable,
878
- body=search.to_dict(count=True),
903
+ body=count_query,
879
904
  )
880
905
  )
881
906
 
@@ -918,7 +943,7 @@ class DatabaseLogic(BaseDatabaseLogic):
918
943
  geometry_geohash_grid_precision: int,
919
944
  geometry_geotile_grid_precision: int,
920
945
  datetime_frequency_interval: str,
921
- datetime_search,
946
+ datetime_search: str,
922
947
  ignore_unavailable: Optional[bool] = True,
923
948
  ):
924
949
  """Return aggregations of STAC Items."""
@@ -978,6 +1003,44 @@ class DatabaseLogic(BaseDatabaseLogic):
978
1003
  if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id):
979
1004
  raise NotFoundError(f"Collection {collection_id} does not exist")
980
1005
 
1006
+ async def _check_item_exists_in_collection(
1007
+ self, collection_id: str, item_id: str
1008
+ ) -> bool:
1009
+ """Check if an item exists across all indexes for a collection.
1010
+
1011
+ Args:
1012
+ collection_id (str): The collection identifier.
1013
+ item_id (str): The item identifier.
1014
+
1015
+ Returns:
1016
+ bool: True if the item exists in any index, False otherwise.
1017
+ """
1018
+ alias = index_alias_by_collection_id(collection_id)
1019
+ doc_id = mk_item_id(item_id, collection_id)
1020
+ try:
1021
+ return await check_item_exists_in_alias(self.client, alias, doc_id)
1022
+ except Exception:
1023
+ return False
1024
+
1025
+ def _check_item_exists_in_collection_sync(
1026
+ self, collection_id: str, item_id: str
1027
+ ) -> bool:
1028
+ """Check if an item exists across all indexes for a collection (sync version).
1029
+
1030
+ Args:
1031
+ collection_id (str): The collection identifier.
1032
+ item_id (str): The item identifier.
1033
+
1034
+ Returns:
1035
+ bool: True if the item exists in any index, False otherwise.
1036
+ """
1037
+ alias = index_alias_by_collection_id(collection_id)
1038
+ doc_id = mk_item_id(item_id, collection_id)
1039
+ try:
1040
+ return check_item_exists_in_alias_sync(self.sync_client, alias, doc_id)
1041
+ except Exception:
1042
+ return False
1043
+
981
1044
  async def async_prep_create_item(
982
1045
  self, item: Item, base_url: str, exist_ok: bool = False
983
1046
  ) -> Item:
@@ -993,31 +1056,21 @@ class DatabaseLogic(BaseDatabaseLogic):
993
1056
  Item: The prepped item.
994
1057
 
995
1058
  Raises:
996
- ConflictError: If the item already exists in the database.
1059
+ ItemAlreadyExistsError: If the item already exists in the database.
997
1060
 
998
1061
  """
999
1062
  await self.check_collection_exists(collection_id=item["collection"])
1000
- alias = index_alias_by_collection_id(item["collection"])
1001
- doc_id = mk_item_id(item["id"], item["collection"])
1002
-
1003
- if not exist_ok:
1004
- alias_exists = await self.client.indices.exists_alias(name=alias)
1005
1063
 
1006
- if alias_exists:
1007
- alias_info = await self.client.indices.get_alias(name=alias)
1008
- indices = list(alias_info.keys())
1009
-
1010
- for index in indices:
1011
- if await self.client.exists(index=index, id=doc_id):
1012
- raise ConflictError(
1013
- f"Item {item['id']} in collection {item['collection']} already exists"
1014
- )
1064
+ if not exist_ok and await self._check_item_exists_in_collection(
1065
+ item["collection"], item["id"]
1066
+ ):
1067
+ raise ItemAlreadyExistsError(item["id"], item["collection"])
1015
1068
 
1016
1069
  return self.item_serializer.stac_to_db(item, base_url)
1017
1070
 
1018
1071
  async def bulk_async_prep_create_item(
1019
1072
  self, item: Item, base_url: str, exist_ok: bool = False
1020
- ) -> Item:
1073
+ ) -> Optional[Item]:
1021
1074
  """
1022
1075
  Prepare an item for insertion into the database.
1023
1076
 
@@ -1045,20 +1098,19 @@ class DatabaseLogic(BaseDatabaseLogic):
1045
1098
  # Check if the collection exists
1046
1099
  await self.check_collection_exists(collection_id=item["collection"])
1047
1100
 
1048
- # Check if the item already exists in the database
1049
- if not exist_ok and await self.client.exists(
1050
- index=index_alias_by_collection_id(item["collection"]),
1051
- id=mk_item_id(item["id"], item["collection"]),
1101
+ # Check if the item already exists in the database (across all datetime indexes)
1102
+ if not exist_ok and await self._check_item_exists_in_collection(
1103
+ item["collection"], item["id"]
1052
1104
  ):
1053
- error_message = (
1054
- f"Item {item['id']} in collection {item['collection']} already exists."
1055
- )
1056
1105
  if self.async_settings.raise_on_bulk_error:
1057
- raise ConflictError(error_message)
1106
+ raise ItemAlreadyExistsError(item["id"], item["collection"])
1058
1107
  else:
1059
1108
  logger.warning(
1060
- f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false."
1109
+ f"Item {item['id']} in collection {item['collection']} already exists. "
1110
+ "Skipping as `RAISE_ON_BULK_ERROR` is set to false."
1061
1111
  )
1112
+ return None
1113
+
1062
1114
  # Serialize the item into a database-compatible format
1063
1115
  prepped_item = self.item_serializer.stac_to_db(item, base_url)
1064
1116
  logger.debug(f"Item {item['id']} prepared successfully.")
@@ -1066,7 +1118,7 @@ class DatabaseLogic(BaseDatabaseLogic):
1066
1118
 
1067
1119
  def bulk_sync_prep_create_item(
1068
1120
  self, item: Item, base_url: str, exist_ok: bool = False
1069
- ) -> Item:
1121
+ ) -> Optional[Item]:
1070
1122
  """
1071
1123
  Prepare an item for insertion into the database.
1072
1124
 
@@ -1095,20 +1147,18 @@ class DatabaseLogic(BaseDatabaseLogic):
1095
1147
  if not self.sync_client.exists(index=COLLECTIONS_INDEX, id=item["collection"]):
1096
1148
  raise NotFoundError(f"Collection {item['collection']} does not exist")
1097
1149
 
1098
- # Check if the item already exists in the database
1099
- if not exist_ok and self.sync_client.exists(
1100
- index=index_alias_by_collection_id(item["collection"]),
1101
- id=mk_item_id(item["id"], item["collection"]),
1150
+ # Check if the item already exists in the database (across all datetime indexes)
1151
+ if not exist_ok and self._check_item_exists_in_collection_sync(
1152
+ item["collection"], item["id"]
1102
1153
  ):
1103
- error_message = (
1104
- f"Item {item['id']} in collection {item['collection']} already exists."
1105
- )
1106
1154
  if self.sync_settings.raise_on_bulk_error:
1107
- raise ConflictError(error_message)
1155
+ raise ItemAlreadyExistsError(item["id"], item["collection"])
1108
1156
  else:
1109
1157
  logger.warning(
1110
- f"{error_message} Continuing as `RAISE_ON_BULK_ERROR` is set to false."
1158
+ f"Item {item['id']} in collection {item['collection']} already exists. "
1159
+ "Skipping as `RAISE_ON_BULK_ERROR` is set to false."
1111
1160
  )
1161
+ return None
1112
1162
 
1113
1163
  # Serialize the item into a database-compatible format
1114
1164
  prepped_item = self.item_serializer.stac_to_db(item, base_url)
@@ -1152,6 +1202,31 @@ class DatabaseLogic(BaseDatabaseLogic):
1152
1202
  f"Creating item {item_id} in collection {collection_id} with refresh={refresh}"
1153
1203
  )
1154
1204
 
1205
+ if exist_ok and isinstance(self.async_index_inserter, DatetimeIndexInserter):
1206
+ existing_item = await self.get_one_item(collection_id, item_id)
1207
+ primary_datetime_name = self.async_index_inserter.primary_datetime_name
1208
+
1209
+ existing_primary_datetime = existing_item.get("properties", {}).get(
1210
+ primary_datetime_name
1211
+ )
1212
+ new_primary_datetime = item.get("properties", {}).get(primary_datetime_name)
1213
+
1214
+ if existing_primary_datetime != new_primary_datetime:
1215
+ self.async_index_inserter.validate_datetime_field_update(
1216
+ f"properties/{primary_datetime_name}"
1217
+ )
1218
+
1219
+ if primary_datetime_name == "start_datetime":
1220
+ existing_end_datetime = existing_item.get("properties", {}).get(
1221
+ "end_datetime"
1222
+ )
1223
+ new_end_datetime = item.get("properties", {}).get("end_datetime")
1224
+
1225
+ if existing_end_datetime != new_end_datetime:
1226
+ self.async_index_inserter.validate_datetime_field_update(
1227
+ "properties/end_datetime"
1228
+ )
1229
+
1155
1230
  item = await self.async_prep_create_item(
1156
1231
  item=item, base_url=base_url, exist_ok=exist_ok
1157
1232
  )
@@ -1219,6 +1294,10 @@ class DatabaseLogic(BaseDatabaseLogic):
1219
1294
  Returns:
1220
1295
  patched item.
1221
1296
  """
1297
+ for operation in operations:
1298
+ if operation.op in ["add", "replace", "remove"]:
1299
+ self.async_index_inserter.validate_datetime_field_update(operation.path)
1300
+
1222
1301
  new_item_id = None
1223
1302
  new_collection_id = None
1224
1303
  script_operations = []
@@ -1238,8 +1317,6 @@ class DatabaseLogic(BaseDatabaseLogic):
1238
1317
  else:
1239
1318
  script_operations.append(operation)
1240
1319
 
1241
- script = operations_to_script(script_operations, create_nest=create_nest)
1242
-
1243
1320
  try:
1244
1321
  search_response = await self.client.search(
1245
1322
  index=index_alias_by_collection_id(collection_id),
@@ -1252,13 +1329,18 @@ class DatabaseLogic(BaseDatabaseLogic):
1252
1329
  raise NotFoundError(
1253
1330
  f"Item {item_id} does not exist inside Collection {collection_id}"
1254
1331
  )
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
- body={"script": script},
1260
- refresh=True,
1261
- )
1332
+
1333
+ if script_operations:
1334
+ script = operations_to_script(
1335
+ script_operations, create_nest=create_nest
1336
+ )
1337
+ document_index = search_response["hits"]["hits"][0]["_index"]
1338
+ await self.client.update(
1339
+ index=document_index,
1340
+ id=mk_item_id(item_id, collection_id),
1341
+ body={"script": script},
1342
+ refresh=True,
1343
+ )
1262
1344
  except exceptions.NotFoundError:
1263
1345
  raise NotFoundError(
1264
1346
  f"Item {item_id} does not exist inside Collection {collection_id}"
@@ -1271,24 +1353,9 @@ class DatabaseLogic(BaseDatabaseLogic):
1271
1353
  item = await self.get_one_item(collection_id, item_id)
1272
1354
 
1273
1355
  if new_collection_id:
1274
- await self.client.reindex(
1275
- body={
1276
- "dest": {"index": f"{ITEMS_INDEX_PREFIX}{new_collection_id}"},
1277
- "source": {
1278
- "index": f"{ITEMS_INDEX_PREFIX}{collection_id}",
1279
- "query": {"term": {"id": {"value": item_id}}},
1280
- },
1281
- "script": {
1282
- "lang": "painless",
1283
- "source": (
1284
- f"""ctx._id = ctx._id.replace('{collection_id}', '{new_collection_id}');"""
1285
- f"""ctx._source.collection = '{new_collection_id}';"""
1286
- ),
1287
- },
1288
- },
1289
- wait_for_completion=True,
1290
- refresh=True,
1291
- )
1356
+ item["collection"] = new_collection_id
1357
+ item = await self.async_prep_create_item(item=item, base_url=base_url)
1358
+ await self.create_item(item=item, refresh=True)
1292
1359
 
1293
1360
  await self.delete_item(
1294
1361
  item_id=item_id,
@@ -1296,7 +1363,6 @@ class DatabaseLogic(BaseDatabaseLogic):
1296
1363
  refresh=refresh,
1297
1364
  )
1298
1365
 
1299
- item["collection"] = new_collection_id
1300
1366
  collection_id = new_collection_id
1301
1367
 
1302
1368
  if new_item_id:
@@ -1657,6 +1723,7 @@ class DatabaseLogic(BaseDatabaseLogic):
1657
1723
  )
1658
1724
  # Delete the item index for the collection
1659
1725
  await delete_item_index(collection_id)
1726
+ await self.async_index_inserter.refresh_cache()
1660
1727
 
1661
1728
  async def bulk_async(
1662
1729
  self,
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "6.9.0"
2
+ __version__ = "6.10.1"