stac-fastapi.core 7.0.0__tar.gz → 7.2.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 (31) hide show
  1. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/PKG-INFO +6 -6
  2. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/pyproject.toml +5 -5
  3. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/base_database_logic.py +6 -1
  4. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/catalogs_client.py +98 -15
  5. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/core.py +34 -5
  6. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/version.py +1 -1
  7. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/.gitignore +0 -0
  8. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/README.md +0 -0
  9. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/pytest.ini +0 -0
  10. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/__init__.py +0 -0
  11. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/base_settings.py +0 -0
  12. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/basic_auth.py +0 -0
  13. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/datetime_utils.py +0 -0
  14. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/exceptions.py +0 -0
  15. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/extensions/__init__.py +0 -0
  16. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/extensions/aggregation.py +0 -0
  17. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/extensions/collections_search.py +0 -0
  18. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/extensions/fields.py +0 -0
  19. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/extensions/filter.py +0 -0
  20. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/extensions/query.py +0 -0
  21. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/models/__init__.py +0 -0
  22. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/models/links.py +0 -0
  23. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/models/search.py +0 -0
  24. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/queryables.py +0 -0
  25. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/rate_limit.py +0 -0
  26. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/redis_utils.py +0 -0
  27. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/route_dependencies.py +0 -0
  28. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/serializers.py +0 -0
  29. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/session.py +0 -0
  30. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/utilities.py +0 -0
  31. {stac_fastapi_core-7.0.0 → stac_fastapi_core-7.2.0}/stac_fastapi/core/validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stac_fastapi_core
3
- Version: 7.0.0
3
+ Version: 7.2.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
@@ -22,19 +22,19 @@ Requires-Dist: overrides<7.8,>=7.4
22
22
  Requires-Dist: pydantic<3.0.0,>=2.4.1
23
23
  Requires-Dist: pygeofilter<0.5.0,>=0.3.1
24
24
  Requires-Dist: slowapi~=0.1.9
25
- Requires-Dist: stac-fastapi-api==6.5.0
26
- Requires-Dist: stac-fastapi-extensions==6.5.0
27
- Requires-Dist: stac-fastapi-types==6.5.0
25
+ Requires-Dist: stac-fastapi-api==7.0.0
26
+ Requires-Dist: stac-fastapi-extensions==7.0.0
27
+ Requires-Dist: stac-fastapi-types==7.0.0
28
28
  Requires-Dist: stac-pydantic<3.7,>=3.3
29
29
  Requires-Dist: starlette<1.7.0,>=0.35.0
30
30
  Requires-Dist: uvicorn<0.53,>=0.23
31
31
  Provides-Extra: catalogs
32
- Requires-Dist: stac-fastapi-catalogs-extension==0.5.0; extra == 'catalogs'
32
+ Requires-Dist: stac-fastapi-catalogs-extension==1.0.0; extra == 'catalogs'
33
33
  Provides-Extra: redis
34
34
  Requires-Dist: redis<8.2,>=6.4; extra == 'redis'
35
35
  Requires-Dist: retry~=0.9.2; extra == 'redis'
36
36
  Provides-Extra: sentry
37
- Requires-Dist: sentry-sdk<2.69,>=2.49; extra == 'sentry'
37
+ Requires-Dist: sentry-sdk<2.70,>=2.49; extra == 'sentry'
38
38
  Provides-Extra: validator
39
39
  Requires-Dist: jsonpatch; extra == 'validator'
40
40
  Requires-Dist: stac-valid<4.6.0,>=4.2.2; extra == 'validator'
@@ -33,9 +33,9 @@ dependencies = [
33
33
  "attrs>=23.2.0",
34
34
  "pydantic>=2.4.1,<3.0.0",
35
35
  "stac_pydantic>=3.3,<3.7",
36
- "stac-fastapi.types==6.5.0",
37
- "stac-fastapi.api==6.5.0",
38
- "stac-fastapi.extensions==6.5.0",
36
+ "stac-fastapi.types==7.0.0",
37
+ "stac-fastapi.api==7.0.0",
38
+ "stac-fastapi.extensions==7.0.0",
39
39
  "orjson>=3.11,<3.13",
40
40
  "overrides>=7.4,<7.8",
41
41
  "pygeofilter>=0.3.1,<0.5.0",
@@ -51,10 +51,10 @@ redis = [
51
51
  "retry~=0.9.2",
52
52
  ]
53
53
  sentry = [
54
- "sentry-sdk>=2.49,<2.69",
54
+ "sentry-sdk>=2.49,<2.70",
55
55
  ]
56
56
  catalogs = [
57
- "stac-fastapi-catalogs-extension==0.5.0",
57
+ "stac-fastapi-catalogs-extension==1.0.0",
58
58
  ]
59
59
  validator = [
60
60
  "stac-valid>=4.2.2,<4.6.0",
@@ -201,7 +201,12 @@ class BaseDatabaseLogic(abc.ABC):
201
201
 
202
202
  @abc.abstractmethod
203
203
  async def delete_catalog(self, catalog_id: str, refresh: bool = False) -> None:
204
- """Delete a catalog from the database."""
204
+ """Delete a catalog from the database.
205
+
206
+ Implementations MUST verify the document exists and is a Catalog
207
+ before deleting, raising NotFoundError otherwise; deleting a catalog
208
+ must never remove Collection or Item data.
209
+ """
205
210
  pass
206
211
 
207
212
  @abc.abstractmethod
@@ -730,9 +730,18 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
730
730
  **kwargs,
731
731
  ) -> Catalog | Response:
732
732
  """Create a new catalog or link an existing catalog as a sub-catalog."""
733
+ # Verify the parent catalog exists
734
+ await self.database.find_catalog(catalog_id)
735
+
733
736
  # Check if it's an existing catalog or a new one
734
737
  cat_id = catalog.id if hasattr(catalog, "id") else catalog.get("id")
735
738
 
739
+ # Detect an ObjectUri payload ({"id": ...} only) vs a full Catalog body
740
+ if isinstance(catalog, dict):
741
+ is_object_uri = len(catalog) == 1 and "id" in catalog
742
+ else:
743
+ is_object_uri = isinstance(catalog, ObjectUri)
744
+
736
745
  try:
737
746
  existing = await self.database.find_catalog(cat_id)
738
747
  # Link existing catalog
@@ -752,8 +761,25 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
752
761
  existing_dict["links"] = [
753
762
  self._link_to_dict(link) for link in existing_dict.get("links", [])
754
763
  ]
755
- return JSONResponse(content=existing_dict, status_code=201)
764
+ # Linking an existing catalog returns 200 OK (Mode B);
765
+ # 201 Created is reserved for newly created catalogs
766
+ headers = (
767
+ {
768
+ "Warning": (
769
+ f'299 - "Catalog {cat_id} already exists and was linked '
770
+ f"to catalog {catalog_id}; posted content was not "
771
+ f'applied. Use PUT /catalogs/{cat_id} to update."'
772
+ )
773
+ }
774
+ if not is_object_uri
775
+ else None
776
+ )
777
+ return JSONResponse(content=existing_dict, status_code=200, headers=headers)
756
778
  except NotFoundError:
779
+ # An ObjectUri payload must reference an existing catalog
780
+ if is_object_uri:
781
+ raise NotFoundError(f"Catalog {cat_id} not found")
782
+
757
783
  # Create new catalog
758
784
  db_catalog_dict = self._to_dict(catalog)
759
785
  db_catalog_dict["type"] = "Catalog"
@@ -805,10 +831,7 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
805
831
  else:
806
832
  col_id = collection.id
807
833
  # Check if this is an ObjectUri (only has id field)
808
- is_object_uri = (
809
- hasattr(collection, "__class__")
810
- and collection.__class__.__name__ == "ObjectUri"
811
- )
834
+ is_object_uri = isinstance(collection, ObjectUri)
812
835
 
813
836
  # If only an ID was provided (ObjectUri), the collection must already exist
814
837
  if is_object_uri:
@@ -833,9 +856,10 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
833
856
  catalog_id=catalog_id,
834
857
  extensions=["CatalogsExtension"],
835
858
  )
836
- # Return 201 Created for all collection operations
859
+ # Linking an existing collection returns 200 OK (Mode B);
860
+ # 201 Created is reserved for newly created collections
837
861
  content = self._to_dict(collection_obj)
838
- return JSONResponse(content=content, status_code=201)
862
+ return JSONResponse(content=content, status_code=200)
839
863
 
840
864
  # Full collection data provided - try to link existing or create new
841
865
  try:
@@ -845,7 +869,9 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
845
869
  # Collection doesn't exist, will create new one below
846
870
  pass
847
871
  else:
848
- # Collection exists, link it
872
+ # Collection exists, link it. POST does not replace the stored
873
+ # document, so return 200 to signal the collection already existed
874
+ # (201 is reserved for a newly created collection document).
849
875
  try:
850
876
  await self.database.update_collection(col_id, existing, refresh=True)
851
877
  except Exception as e:
@@ -861,9 +887,19 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
861
887
  catalog_id=catalog_id,
862
888
  extensions=["CatalogsExtension"],
863
889
  )
864
- # Return 201 Created for full collection data (even if linking existing)
865
890
  content = self._to_dict(collection_obj)
866
- return JSONResponse(content=content, status_code=201)
891
+ return JSONResponse(
892
+ content=content,
893
+ status_code=200,
894
+ headers={
895
+ "Warning": (
896
+ f'299 - "Collection {col_id} already exists and was '
897
+ f"linked to catalog {catalog_id}; posted content was "
898
+ f"not applied. Use PUT /catalogs/{catalog_id}/"
899
+ f'collections/{col_id} to update."'
900
+ )
901
+ },
902
+ )
867
903
 
868
904
  # Create new collection
869
905
  col_dict = self._to_dict(collection)
@@ -1410,6 +1446,44 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
1410
1446
  filtered_links = [self._link_to_dict(link) for link in links]
1411
1447
  children_dicts = [self._to_dict(child) for child in children]
1412
1448
 
1449
+ # Per STAC API - Children v1.0.0, every child entity MUST include
1450
+ # self, root, and parent link relations
1451
+ for child_dict, raw_child in zip(children_dicts, children_list):
1452
+ child_id = raw_child.get("id")
1453
+ if raw_child.get("type") == "Catalog":
1454
+ self_href = f"{base_url}/catalogs/{child_id}"
1455
+ else:
1456
+ self_href = f"{base_url}/catalogs/{catalog_id}/collections/{child_id}"
1457
+
1458
+ child_links = [
1459
+ link
1460
+ for link in child_dict.get("links", [])
1461
+ if link.get("rel") not in ("self", "root", "parent")
1462
+ ]
1463
+ child_dict["links"] = [
1464
+ self._link_to_dict(link)
1465
+ for link in [
1466
+ {
1467
+ "rel": "self",
1468
+ "type": "application/json",
1469
+ "href": self_href,
1470
+ },
1471
+ {
1472
+ "rel": "parent",
1473
+ "type": "application/json",
1474
+ "href": f"{base_url}/catalogs/{catalog_id}",
1475
+ "title": "Parent Catalog",
1476
+ },
1477
+ {
1478
+ "rel": "root",
1479
+ "type": "application/json",
1480
+ "href": base_url,
1481
+ "title": "Root Catalog",
1482
+ },
1483
+ *child_links,
1484
+ ]
1485
+ ]
1486
+
1413
1487
  return JSONResponse(
1414
1488
  content={
1415
1489
  "children": children_dicts,
@@ -1422,14 +1496,23 @@ class CatalogsClient(AsyncBaseCatalogsClient, AsyncCatalogsSearchClient):
1422
1496
  async def get_catalog_conformance(
1423
1497
  self, catalog_id: str, request: Request | None = None, **kwargs
1424
1498
  ) -> dict | Response:
1425
- """Get conformance classes specific to this sub-catalog."""
1426
- # Return standard conformance classes for now
1499
+ """Get conformance classes specific to this sub-catalog.
1500
+
1501
+ SFEOS always enables the transaction and scoped search extensions
1502
+ alongside the catalogs extension, so their conformance classes are
1503
+ advertised here. The extension also merges in the conformance classes
1504
+ of registered catalog extensions via
1505
+ ``app.state.catalogs_conformance_classes``.
1506
+ """
1427
1507
  return {
1428
1508
  "conformsTo": [
1429
1509
  "https://api.stacspec.org/v1.0.0/core",
1430
- "https://api.stacspec.org/v1.0.0-rc.1/multi-tenant-catalogs",
1431
- "https://api.stacspec.org/v1.0.0-rc.1/multi-tenant-catalogs/transaction",
1432
- "https://api.stacspec.org/v1.0.0-rc.2/children",
1510
+ "https://api.stacspec.org/v1.0.0/multi-tenant-catalogs",
1511
+ "https://api.stacspec.org/v1.0.0/multi-tenant-catalogs/transaction",
1512
+ "https://api.stacspec.org/v1.0.0/multi-tenant-catalogs/search",
1513
+ "https://api.stacspec.org/v1.0.0/children",
1514
+ "https://api.stacspec.org/v1.0.0/children#type-filter",
1515
+ "https://api.stacspec.org/v1.0.0/item-search",
1433
1516
  ]
1434
1517
  }
1435
1518
 
@@ -6,7 +6,7 @@ import os
6
6
  from datetime import datetime as datetime_type
7
7
  from datetime import timezone
8
8
  from enum import Enum
9
- from typing import Type
9
+ from typing import Type, cast
10
10
  from urllib.parse import unquote_plus, urljoin
11
11
 
12
12
  import attr
@@ -54,6 +54,7 @@ from stac_fastapi.core.validate import (
54
54
  )
55
55
  from stac_fastapi.extensions.bulk_transactions import (
56
56
  BaseBulkTransactionsClient,
57
+ BulkTransaction,
57
58
  BulkTransactionMethod,
58
59
  Items,
59
60
  )
@@ -2030,7 +2031,7 @@ class BulkTransactionsClient(BaseBulkTransactionsClient):
2030
2031
  @overrides
2031
2032
  def bulk_item_insert(
2032
2033
  self, items: Items, chunk_size: int | None = None, **kwargs
2033
- ) -> str:
2034
+ ) -> BulkTransaction | Response:
2034
2035
  """Perform a bulk insertion of items into the database using Elasticsearch.
2035
2036
 
2036
2037
  Args:
@@ -2075,7 +2076,15 @@ class BulkTransactionsClient(BaseBulkTransactionsClient):
2075
2076
  skipped_batch_duplicates = len(raw_items) - len(unique_items)
2076
2077
 
2077
2078
  if not unique_items:
2078
- return f"No items to insert. {skipped_batch_duplicates} items were skipped (duplicates)."
2079
+ return cast(
2080
+ BulkTransaction,
2081
+ {
2082
+ "received": len(raw_items),
2083
+ "success": 0,
2084
+ "skipped": skipped_batch_duplicates,
2085
+ "errors": [],
2086
+ },
2087
+ )
2079
2088
 
2080
2089
  # 2. VALIDATION LAYER (Use batch validator for efficiency)
2081
2090
  if get_bool_env("ENABLE_STAC_VALIDATOR"):
@@ -2115,7 +2124,15 @@ class BulkTransactionsClient(BaseBulkTransactionsClient):
2115
2124
  processed_items.append(prepped)
2116
2125
 
2117
2126
  if not processed_items:
2118
- return f"No items to insert after preprocessing. Skipped {skipped_batch_duplicates} duplicates."
2127
+ return cast(
2128
+ BulkTransaction,
2129
+ {
2130
+ "received": len(raw_items),
2131
+ "success": 0,
2132
+ "skipped": skipped_batch_duplicates + len(valid_items),
2133
+ "errors": [],
2134
+ },
2135
+ )
2119
2136
 
2120
2137
  # 4. DATABASE INSERTION LAYER
2121
2138
  collection_id = processed_items[0]["collection"]
@@ -2141,4 +2158,16 @@ class BulkTransactionsClient(BaseBulkTransactionsClient):
2141
2158
  logger.info(f"Bulk sync operation succeeded with {success} actions.")
2142
2159
 
2143
2160
  total_skipped = skipped_batch_duplicates + len(conflict_errors)
2144
- return f"Successfully added/updated {success} Items. {total_skipped} skipped (duplicates). {len(other_errors)} errors occurred."
2161
+
2162
+ # Combine all errors (conflicts + other errors)
2163
+ all_errors = conflict_errors + other_errors if other_errors else conflict_errors
2164
+
2165
+ return cast(
2166
+ BulkTransaction,
2167
+ {
2168
+ "received": len(raw_items),
2169
+ "success": success,
2170
+ "skipped": total_skipped,
2171
+ "errors": all_errors if all_errors else [],
2172
+ },
2173
+ )
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "7.0.0"
2
+ __version__ = "7.2.0"