stac-fastapi-core 6.8.0__tar.gz → 6.9.0__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.
Files changed (29) hide show
  1. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/PKG-INFO +1 -1
  2. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/core.py +1 -1
  3. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/catalogs.py +60 -74
  4. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/collections_search.py +25 -21
  5. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/redis_utils.py +1 -1
  6. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/utilities.py +84 -6
  7. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/version.py +1 -1
  8. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/.gitignore +0 -0
  9. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/README.md +0 -0
  10. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/pyproject.toml +0 -0
  11. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/pytest.ini +0 -0
  12. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/__init__.py +0 -0
  13. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/base_database_logic.py +0 -0
  14. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/base_settings.py +0 -0
  15. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/basic_auth.py +0 -0
  16. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/datetime_utils.py +0 -0
  17. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/__init__.py +0 -0
  18. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/aggregation.py +0 -0
  19. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/fields.py +0 -0
  20. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/filter.py +0 -0
  21. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/extensions/query.py +0 -0
  22. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/models/__init__.py +0 -0
  23. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/models/links.py +0 -0
  24. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/models/search.py +0 -0
  25. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/queryables.py +0 -0
  26. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/rate_limit.py +0 -0
  27. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/route_dependencies.py +0 -0
  28. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/serializers.py +0 -0
  29. {stac_fastapi_core-6.8.0 → stac_fastapi_core-6.9.0}/stac_fastapi/core/session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stac_fastapi_core
3
- Version: 6.8.0
3
+ Version: 6.9.0
4
4
  Summary: Core library for the Elasticsearch and Opensearch stac-fastapi backends.
5
5
  Project-URL: Homepage, https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch
6
6
  License: MIT
@@ -315,7 +315,7 @@ class CoreClient(AsyncBaseCoreClient):
315
315
 
316
316
  body_limit = None
317
317
  try:
318
- if request.method == "POST" and request.body():
318
+ if request.method == "POST" and await request.body():
319
319
  body_data = await request.json()
320
320
  body_limit = body_data.get("limit")
321
321
  except Exception:
@@ -56,6 +56,7 @@ class CatalogsExtension(ApiExtension):
56
56
  settings: extension settings (unused for now).
57
57
  """
58
58
  self.settings = settings or {}
59
+ self.router = APIRouter()
59
60
 
60
61
  self.router.add_api_route(
61
62
  path="/catalogs",
@@ -126,7 +127,7 @@ class CatalogsExtension(ApiExtension):
126
127
  response_class=self.response_class,
127
128
  status_code=204,
128
129
  summary="Delete Catalog",
129
- description="Delete a catalog. Optionally cascade delete all collections in the catalog.",
130
+ description="Delete a catalog. All linked collections are unlinked and adopted by root if orphaned.",
130
131
  tags=["Catalogs"],
131
132
  )
132
133
 
@@ -337,22 +338,21 @@ class CatalogsExtension(ApiExtension):
337
338
  status_code=404, detail=f"Catalog {catalog_id} not found"
338
339
  )
339
340
 
340
- async def delete_catalog(
341
- self,
342
- catalog_id: str,
343
- request: Request,
344
- cascade: bool = Query(
345
- False,
346
- description="If true, delete all collections linked to this catalog. If false, only delete the catalog.",
347
- ),
348
- ) -> None:
349
- """Delete a catalog.
341
+ async def delete_catalog(self, catalog_id: str, request: Request) -> None:
342
+ """Delete a catalog (The Container).
343
+
344
+ Deletes the Catalog document itself. All linked Collections are unlinked
345
+ and adopted by Root if they become orphans. Collection data is NEVER deleted.
346
+
347
+ Logic:
348
+ 1. Finds all Collections linked to this Catalog.
349
+ 2. Unlinks them (removes catalog_id from their parent_ids).
350
+ 3. If a Collection becomes an orphan, it is adopted by Root.
351
+ 4. PERMANENTLY DELETES the Catalog document itself.
350
352
 
351
353
  Args:
352
354
  catalog_id: The ID of the catalog to delete.
353
355
  request: Request object.
354
- cascade: If true, delete all collections linked to this catalog.
355
- If false, only delete the catalog.
356
356
 
357
357
  Returns:
358
358
  None (204 No Content)
@@ -361,58 +361,42 @@ class CatalogsExtension(ApiExtension):
361
361
  HTTPException: If the catalog is not found.
362
362
  """
363
363
  try:
364
- # Get the catalog to verify it exists
364
+ # Verify the catalog exists
365
365
  await self.client.database.find_catalog(catalog_id)
366
366
 
367
- # Use reverse lookup query to find all collections with this catalog in parent_ids.
368
- # This is more reliable than parsing links, as it captures all collections
369
- # regardless of pagination or link truncation.
370
- query_body = {"query": {"term": {"parent_ids": catalog_id}}}
367
+ # Find all collections with this catalog in parent_ids
368
+ query_body = {"query": {"term": {"parent_ids": catalog_id}}, "size": 10000}
371
369
  search_result = await self.client.database.client.search(
372
- index=COLLECTIONS_INDEX, body=query_body, size=10000
370
+ index=COLLECTIONS_INDEX, body=query_body
373
371
  )
374
372
  children = [hit["_source"] for hit in search_result["hits"]["hits"]]
375
373
 
376
- # Process each child collection
374
+ # Safe Unlink: Remove catalog from all children's parent_ids
375
+ # If a child becomes an orphan, adopt it to root
376
+ root_id = self.settings.get("STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi")
377
+
377
378
  for child in children:
378
379
  child_id = child.get("id")
379
380
  try:
380
- if cascade:
381
- # DANGER ZONE: User explicitly requested cascade delete.
382
- # Delete the collection entirely, regardless of other parents.
383
- await self.client.database.delete_collection(child_id)
384
- logger.info(
385
- f"Deleted collection {child_id} as part of cascade delete for catalog {catalog_id}"
386
- )
387
- else:
388
- # SAFE ZONE: Smart Unlink - Remove only this catalog from parent_ids.
389
- # The collection survives and becomes a root-level collection if it has no other parents.
390
- parent_ids = child.get("parent_ids", [])
391
- if catalog_id in parent_ids:
392
- parent_ids.remove(catalog_id)
393
- child["parent_ids"] = parent_ids
394
-
395
- # Update the collection in the database
396
- # Note: Catalog links are now dynamically generated, so no need to remove them
397
- await self.client.database.update_collection(
398
- collection_id=child_id,
399
- collection=child,
400
- refresh=False,
381
+ parent_ids = child.get("parent_ids", [])
382
+ if catalog_id in parent_ids:
383
+ parent_ids.remove(catalog_id)
384
+
385
+ # If orphan, move to root
386
+ if len(parent_ids) == 0:
387
+ parent_ids.append(root_id)
388
+ logger.info(
389
+ f"Collection {child_id} adopted by root after catalog deletion."
401
390
  )
402
-
403
- # Log the result
404
- if len(parent_ids) == 0:
405
- logger.info(
406
- f"Collection {child_id} is now a root-level orphan (no parent catalogs)"
407
- )
408
- else:
409
- logger.info(
410
- f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)"
411
- )
412
391
  else:
413
- logger.debug(
414
- f"Catalog {catalog_id} not in parent_ids for collection {child_id}"
392
+ logger.info(
393
+ f"Removed catalog {catalog_id} from collection {child_id}; still belongs to {len(parent_ids)} other catalog(s)"
415
394
  )
395
+
396
+ child["parent_ids"] = parent_ids
397
+ await self.client.database.update_collection(
398
+ collection_id=child_id, collection=child, refresh=False
399
+ )
416
400
  except Exception as e:
417
401
  error_msg = str(e)
418
402
  if "not found" in error_msg.lower():
@@ -929,11 +913,11 @@ class CatalogsExtension(ApiExtension):
929
913
  async def delete_catalog_collection(
930
914
  self, catalog_id: str, collection_id: str, request: Request
931
915
  ) -> None:
932
- """Delete a collection from a catalog.
916
+ """Delete a collection from a catalog (Unlink only).
933
917
 
934
- If the collection has multiple parent catalogs, only removes this catalog
935
- from the parent_ids. If this is the only parent catalog, deletes the
936
- collection entirely.
918
+ Removes the catalog from the collection's parent_ids.
919
+ If the collection becomes an orphan (no parents), it is adopted by the Root.
920
+ It NEVER deletes the collection data.
937
921
 
938
922
  Args:
939
923
  catalog_id: The ID of the catalog.
@@ -959,37 +943,39 @@ class CatalogsExtension(ApiExtension):
959
943
  detail=f"Collection {collection_id} does not belong to catalog {catalog_id}",
960
944
  )
961
945
 
962
- # If the collection has multiple parents, just remove this catalog from parent_ids
963
- if len(parent_ids) > 1:
964
- parent_ids.remove(catalog_id)
965
- collection_db["parent_ids"] = parent_ids
946
+ # SAFE UNLINK LOGIC
947
+ parent_ids.remove(catalog_id)
966
948
 
967
- # Update the collection in the database
968
- # Note: Catalog links are now dynamically generated, so no need to remove them
969
- await self.client.database.update_collection(
970
- collection_id=collection_id, collection=collection_db, refresh=True
949
+ # Check if it is now an orphan (empty list)
950
+ if len(parent_ids) == 0:
951
+ # Fallback to Root / Landing Page
952
+ # You can hardcode 'root' or fetch the ID from settings
953
+ root_id = self.settings.get(
954
+ "STAC_FASTAPI_LANDING_PAGE_ID", "stac-fastapi"
971
955
  )
972
-
956
+ parent_ids.append(root_id)
973
957
  logger.info(
974
- f"Removed catalog {catalog_id} from collection {collection_id} parent_ids"
958
+ f"Collection {collection_id} unlinked from {catalog_id}. Orphaned, so adopted by root ({root_id})."
975
959
  )
976
960
  else:
977
- # If this is the only parent, delete the collection entirely
978
- await self.client.database.delete_collection(
979
- collection_id, refresh=True
980
- )
981
961
  logger.info(
982
- f"Deleted collection {collection_id} (only parent was catalog {catalog_id})"
962
+ f"Removed catalog {catalog_id} from collection {collection_id}; still belongs to {len(parent_ids)} other catalog(s)"
983
963
  )
984
964
 
965
+ # Update the collection in the database
966
+ collection_db["parent_ids"] = parent_ids
967
+ await self.client.database.update_collection(
968
+ collection_id=collection_id, collection=collection_db, refresh=True
969
+ )
970
+
985
971
  except HTTPException:
986
972
  raise
987
973
  except Exception as e:
988
974
  logger.error(
989
- f"Error deleting collection {collection_id} from catalog {catalog_id}: {e}",
975
+ f"Error removing collection {collection_id} from catalog {catalog_id}: {e}",
990
976
  exc_info=True,
991
977
  )
992
978
  raise HTTPException(
993
979
  status_code=500,
994
- detail=f"Failed to delete collection from catalog: {str(e)}",
980
+ detail=f"Failed to remove collection from catalog: {str(e)}",
995
981
  )
@@ -38,7 +38,7 @@ def build_get_collections_search_doc(original_endpoint):
38
38
  query: Optional[str] = Query(
39
39
  None,
40
40
  description="Additional filtering expressed as a string (legacy support)",
41
- example="platform=landsat AND collection_category=level2",
41
+ examples=["platform=landsat AND collection_category=level2"],
42
42
  ),
43
43
  limit: int = Query(
44
44
  10,
@@ -83,14 +83,16 @@ def build_get_collections_search_doc(original_endpoint):
83
83
  description=(
84
84
  "Structured filter expression in CQL2 JSON or CQL2-text format"
85
85
  ),
86
- example='{"op": "=", "args": [{"property": "properties.category"}, "level2"]}',
86
+ examples=[
87
+ '{"op": "=", "args": [{"property": "properties.category"}, "level2"]}'
88
+ ],
87
89
  ),
88
90
  filter_lang: Optional[str] = Query(
89
91
  None,
90
92
  description=(
91
93
  "Filter language. Must be 'cql2-json' or 'cql2-text' if specified"
92
94
  ),
93
- example="cql2-json",
95
+ examples=["cql2-json"],
94
96
  ),
95
97
  ):
96
98
  # Delegate to original endpoint with parameters
@@ -160,24 +162,26 @@ def build_post_collections_search_doc(original_post_endpoint):
160
162
  "- `sortby`: List of sort criteria objects with 'field' and 'direction' (asc/desc)\n"
161
163
  "- `fields`: Object with 'include' and 'exclude' arrays for field selection"
162
164
  ),
163
- example={
164
- "q": "landsat",
165
- "query": "platform=landsat AND collection_category=level2",
166
- "filter": {
167
- "op": "=",
168
- "args": [{"property": "properties.category"}, "level2"],
169
- },
170
- "filter_lang": "cql2-json",
171
- "limit": 10,
172
- "token": "next-page-token",
173
- "bbox": [-180, -90, 180, 90],
174
- "datetime": "2020-01-01T00:00:00Z/2021-01-01T12:31:12Z",
175
- "sortby": [{"field": "id", "direction": "asc"}],
176
- "fields": {
177
- "include": ["id", "title", "description"],
178
- "exclude": ["properties"],
179
- },
180
- },
165
+ examples=[
166
+ {
167
+ "q": "landsat",
168
+ "query": "platform=landsat AND collection_category=level2",
169
+ "filter": {
170
+ "op": "=",
171
+ "args": [{"property": "properties.category"}, "level2"],
172
+ },
173
+ "filter_lang": "cql2-json",
174
+ "limit": 10,
175
+ "token": "next-page-token",
176
+ "bbox": [-180, -90, 180, 90],
177
+ "datetime": "2020-01-01T00:00:00Z/2021-01-01T12:31:12Z",
178
+ "sortby": [{"field": "id", "direction": "asc"}],
179
+ "fields": {
180
+ "include": ["id", "title", "description"],
181
+ "exclude": ["properties"],
182
+ },
183
+ }
184
+ ],
181
185
  ),
182
186
  ) -> Union[Collections, Response]:
183
187
  return await original_post_endpoint(request, body)
@@ -292,4 +292,4 @@ async def redis_pagination_links(
292
292
  except Exception as e:
293
293
  logger.warning(f"Redis pagination operation failed: {e}")
294
294
  finally:
295
- await redis.close()
295
+ await redis.aclose() # type: ignore
@@ -6,6 +6,7 @@ such as converting bounding boxes to polygon representations.
6
6
 
7
7
  import logging
8
8
  import os
9
+ import re
9
10
  from typing import Any, Dict, List, Optional, Set, Union
10
11
 
11
12
  from stac_fastapi.types.stac import Item
@@ -70,8 +71,6 @@ def bbox2polygon(b0: float, b1: float, b2: float, b3: float) -> List[List[List[f
70
71
  return [[[b0, b1], [b2, b1], [b2, b3], [b0, b3], [b0, b1]]]
71
72
 
72
73
 
73
- # copied from stac-fastapi-pgstac
74
- # https://github.com/stac-utils/stac-fastapi-pgstac/blob/26f6d918eb933a90833f30e69e21ba3b4e8a7151/stac_fastapi/pgstac/utils.py#L10-L116
75
74
  def filter_fields( # noqa: C901
76
75
  item: Union[Item, Dict[str, Any]],
77
76
  include: Optional[Set[str]] = None,
@@ -87,15 +86,60 @@ def filter_fields( # noqa: C901
87
86
  if not include and not exclude:
88
87
  return item
89
88
 
90
- # Build a shallow copy of included fields on an item, or a sub-tree of an item
89
+ def match_pattern(pattern: str, key: str) -> bool:
90
+ """Check if a key matches a wildcard pattern."""
91
+ regex_pattern = "^" + re.escape(pattern).replace(r"\*", ".*") + "$"
92
+ return bool(re.match(regex_pattern, key))
93
+
94
+ def get_matching_keys(source: Dict[str, Any], pattern: str) -> List[str]:
95
+ """Get all keys that match the pattern."""
96
+ if not isinstance(source, dict):
97
+ return []
98
+ return [key for key in source.keys() if match_pattern(pattern, key)]
99
+
91
100
  def include_fields(
92
101
  source: Dict[str, Any], fields: Optional[Set[str]]
93
102
  ) -> Dict[str, Any]:
103
+ """Include only the specified fields from the source dictionary."""
94
104
  if not fields:
95
105
  return source
96
106
 
107
+ def recursive_include(
108
+ source: Dict[str, Any], path_parts: List[str]
109
+ ) -> Dict[str, Any]:
110
+ """Recursively include fields matching the pattern path."""
111
+ if not path_parts:
112
+ return source
113
+
114
+ if not isinstance(source, dict):
115
+ return {}
116
+
117
+ current_pattern = path_parts[0]
118
+ remaining_parts = path_parts[1:]
119
+
120
+ matching_keys = get_matching_keys(source, current_pattern)
121
+
122
+ if not matching_keys:
123
+ return {}
124
+
125
+ result: Dict[str, Any] = {}
126
+ for key in matching_keys:
127
+ if remaining_parts:
128
+ if isinstance(source[key], dict):
129
+ value = recursive_include(source[key], remaining_parts)
130
+ if value:
131
+ result[key] = value
132
+ else:
133
+ result[key] = source[key]
134
+
135
+ return result
136
+
97
137
  clean_item: Dict[str, Any] = {}
98
138
  for key_path in fields or []:
139
+ if "*" in key_path:
140
+ value = recursive_include(source, key_path.split("."))
141
+ dict_deep_update(clean_item, value)
142
+ continue
99
143
  key_path_parts = key_path.split(".")
100
144
  key_root = key_path_parts[0]
101
145
  if key_root in source:
@@ -125,12 +169,46 @@ def filter_fields( # noqa: C901
125
169
  # The key, or root key of a multi-part key, is not present in the item,
126
170
  # so it is ignored
127
171
  pass
172
+
128
173
  return clean_item
129
174
 
130
- # For an item built up for included fields, remove excluded fields. This
131
- # modifies `source` in place.
132
- def exclude_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> None:
175
+ def exclude_fields(
176
+ source: Dict[str, Any],
177
+ fields: Optional[Set[str]],
178
+ ) -> None:
179
+ """Exclude fields from source."""
180
+
181
+ def recursive_exclude(
182
+ source: Dict[str, Any], path_parts: List[str], current_path: str = ""
183
+ ) -> None:
184
+ """Recursively exclude fields matching the pattern path."""
185
+ if not path_parts or not isinstance(source, dict):
186
+ return
187
+
188
+ current_pattern = path_parts[0]
189
+ remaining_parts = path_parts[1:]
190
+
191
+ matching_keys = get_matching_keys(source, current_pattern)
192
+
193
+ for key in list(matching_keys):
194
+ if key not in source:
195
+ continue
196
+
197
+ # Build the full path for this key
198
+ full_path = f"{current_path}.{key}" if current_path else key
199
+
200
+ if remaining_parts:
201
+ if isinstance(source[key], dict):
202
+ recursive_exclude(source[key], remaining_parts, full_path)
203
+ if not source[key]:
204
+ del source[key]
205
+ else:
206
+ source.pop(key, None)
207
+
133
208
  for key_path in fields or []:
209
+ if "*" in key_path:
210
+ recursive_exclude(source, key_path.split("."))
211
+ continue
134
212
  key_path_part = key_path.split(".")
135
213
  key_root = key_path_part[0]
136
214
  if key_root in source:
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "6.8.0"
2
+ __version__ = "6.9.0"