stac-fastapi-core 6.7.6__py3-none-any.whl → 6.8.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.
@@ -1 +1,27 @@
1
- """stac_fastapi.elasticsearch.models module."""
1
+ """STAC models."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class Catalog(BaseModel):
9
+ """STAC Catalog model."""
10
+
11
+ type: str = "Catalog"
12
+ stac_version: str
13
+ id: str
14
+ title: Optional[str] = None
15
+ description: Optional[str] = None
16
+ links: List[Dict[str, Any]]
17
+ stac_extensions: Optional[List[str]] = None
18
+
19
+
20
+ class PartialCatalog(BaseModel):
21
+ """Partial STAC Catalog model for updates."""
22
+
23
+ id: str
24
+ title: Optional[str] = None
25
+ description: Optional[str] = None
26
+ links: Optional[List[Dict[str, Any]]] = None
27
+ stac_extensions: Optional[List[str]] = None
@@ -113,6 +113,7 @@ class CollectionLinks(BaseLinks):
113
113
 
114
114
  collection_id: str = attr.ib()
115
115
  extensions: List[str] = attr.ib(default=attr.Factory(list))
116
+ parent_url: Optional[str] = attr.ib(default=None, kw_only=True)
116
117
 
117
118
  def link_self(self) -> Dict:
118
119
  """Return the self link."""
@@ -123,8 +124,14 @@ class CollectionLinks(BaseLinks):
123
124
  )
124
125
 
125
126
  def link_parent(self) -> Dict[str, Any]:
126
- """Create the `parent` link."""
127
- return dict(rel=Relations.parent, type=MimeTypes.json.value, href=self.base_url)
127
+ """Create the `parent` link.
128
+
129
+ The parent link represents the structural parent (the path the user is traversing):
130
+ - If accessed via /catalogs/{id}/collections/{id}, parent is the catalog
131
+ - If accessed via /collections/{id}, parent is the root landing page
132
+ """
133
+ parent_href = self.parent_url if self.parent_url else self.base_url
134
+ return dict(rel=Relations.parent, type=MimeTypes.json.value, href=parent_href)
128
135
 
129
136
  def link_items(self) -> Dict[str, Any]:
130
137
  """Create the `items` link."""
@@ -0,0 +1,105 @@
1
+ """A module for managing queryable attributes."""
2
+
3
+ import asyncio
4
+ import os
5
+ import time
6
+ from typing import Any, Dict, List, Set
7
+
8
+ from fastapi import HTTPException
9
+
10
+
11
+ class QueryablesCache:
12
+ """A thread-safe, time-based cache for queryable properties."""
13
+
14
+ def __init__(self, database_logic: Any):
15
+ """
16
+ Initialize the QueryablesCache.
17
+
18
+ Args:
19
+ database_logic: An instance of a class with a `get_queryables_mapping` method.
20
+ """
21
+ self._db_logic = database_logic
22
+ self._cache: Dict[str, List[str]] = {}
23
+ self._all_queryables: Set[str] = set()
24
+ self._last_updated: float = 0
25
+ self._lock = asyncio.Lock()
26
+ self.validation_enabled: bool = False
27
+ self.cache_ttl: int = 1800 # How often to refresh cache (in seconds)
28
+ self.reload_settings()
29
+
30
+ def reload_settings(self):
31
+ """Reload settings from environment variables."""
32
+ self.validation_enabled = (
33
+ os.getenv("VALIDATE_QUERYABLES", "false").lower() == "true"
34
+ )
35
+ self.cache_ttl = int(os.getenv("QUERYABLES_CACHE_TTL", "1800"))
36
+
37
+ async def _update_cache(self):
38
+ """Update the cache with the latest queryables from the database."""
39
+ if not self.validation_enabled:
40
+ return
41
+
42
+ async with self._lock:
43
+ if (time.time() - self._last_updated < self.cache_ttl) and self._cache:
44
+ return
45
+
46
+ queryables_mapping = await self._db_logic.get_queryables_mapping()
47
+ all_queryables_set = set(queryables_mapping.keys())
48
+
49
+ self._all_queryables = all_queryables_set
50
+
51
+ self._cache = {"*": list(all_queryables_set)}
52
+ self._last_updated = time.time()
53
+
54
+ async def get_all_queryables(self) -> Set[str]:
55
+ """
56
+ Return a set of all queryable attributes across all collections.
57
+
58
+ This method will update the cache if it's stale or has been cleared.
59
+ """
60
+ if not self.validation_enabled:
61
+ return set()
62
+
63
+ if (time.time() - self._last_updated >= self.cache_ttl) or not self._cache:
64
+ await self._update_cache()
65
+ return self._all_queryables
66
+
67
+ async def validate(self, fields: Set[str]) -> None:
68
+ """
69
+ Validate if the provided fields are queryable.
70
+
71
+ Raises HTTPException if invalid fields are found.
72
+ """
73
+ if not self.validation_enabled:
74
+ return
75
+
76
+ allowed_fields = await self.get_all_queryables()
77
+ invalid_fields = fields - allowed_fields
78
+ if invalid_fields:
79
+ raise HTTPException(
80
+ status_code=400,
81
+ detail=f"Invalid query fields: {', '.join(sorted(invalid_fields))}. "
82
+ "These fields are not defined in the collection's queryables. "
83
+ "Use the /queryables endpoint to see available fields.",
84
+ )
85
+
86
+
87
+ def get_properties_from_cql2_filter(cql2_filter: Dict[str, Any]) -> Set[str]:
88
+ """Recursively extract property names from a CQL2 filter.
89
+
90
+ Property names are normalized by stripping the 'properties.' prefix
91
+ if present, to match queryables stored without the prefix.
92
+ """
93
+ props: Set[str] = set()
94
+ if "op" in cql2_filter and "args" in cql2_filter:
95
+ for arg in cql2_filter["args"]:
96
+ if isinstance(arg, dict):
97
+ if "op" in arg:
98
+ props.update(get_properties_from_cql2_filter(arg))
99
+ elif "property" in arg:
100
+ prop_name = arg["property"]
101
+ # Strip 'properties.' prefix if present
102
+ if prop_name.startswith("properties."):
103
+ prop_name = prop_name[11:]
104
+ props.add(prop_name)
105
+ return props
@@ -10,6 +10,7 @@ import attr
10
10
  from starlette.requests import Request
11
11
 
12
12
  from stac_fastapi.core.datetime_utils import now_to_rfc3339_str
13
+ from stac_fastapi.core.models import Catalog
13
14
  from stac_fastapi.core.models.links import CollectionLinks
14
15
  from stac_fastapi.core.utilities import get_bool_env, get_excluded_from_items
15
16
  from stac_fastapi.types import stac as stac_types
@@ -180,8 +181,9 @@ class CollectionSerializer(Serializer):
180
181
  # Avoid modifying the input dict in-place ... doing so breaks some tests
181
182
  collection = deepcopy(collection)
182
183
 
183
- # Remove internal bbox_shape field (not part of STAC spec)
184
+ # Remove internal fields (not part of STAC spec)
184
185
  collection.pop("bbox_shape", None)
186
+ collection.pop("parent_ids", None)
185
187
 
186
188
  # Set defaults
187
189
  collection_id = collection.get("id")
@@ -208,6 +210,7 @@ class CollectionSerializer(Serializer):
208
210
  original_links = collection.get("links")
209
211
  if original_links:
210
212
  collection_links += resolve_links(original_links, str(request.base_url))
213
+
211
214
  collection["links"] = collection_links
212
215
 
213
216
  if get_bool_env("STAC_INDEX_ASSETS"):
@@ -225,3 +228,146 @@ class CollectionSerializer(Serializer):
225
228
 
226
229
  # Return the stac_types.Collection object
227
230
  return stac_types.Collection(**collection)
231
+
232
+ @classmethod
233
+ def db_to_stac_in_catalog(
234
+ cls,
235
+ collection: dict,
236
+ request: Request,
237
+ catalog_id: str,
238
+ extensions: Optional[List[str]] = [],
239
+ ) -> stac_types.Collection:
240
+ """Transform database model to STAC collection within a catalog context.
241
+
242
+ This method is used when a collection is accessed via /catalogs/{id}/collections/{id}.
243
+ It sets the structural parent to the catalog and injects a catalog link.
244
+
245
+ Args:
246
+ collection (dict): The collection data in dictionary form, extracted from the database.
247
+ request: the API request
248
+ catalog_id: The ID of the parent catalog (sets structural parent)
249
+ extensions: A list of the extension class names (`ext.__name__`) or all enabled STAC API extensions.
250
+
251
+ Returns:
252
+ stac_types.Collection: The STAC collection object with catalog context.
253
+ """
254
+ # Avoid modifying the input dict in-place
255
+ collection = deepcopy(collection)
256
+
257
+ # Remove internal fields (not part of STAC spec)
258
+ collection.pop("bbox_shape", None)
259
+
260
+ # Set defaults
261
+ collection_id = collection.get("id")
262
+ collection.setdefault("type", "Collection")
263
+ collection.setdefault("stac_extensions", [])
264
+ collection.setdefault("stac_version", "")
265
+ collection.setdefault("title", "")
266
+ collection.setdefault("description", "")
267
+ collection.setdefault("keywords", [])
268
+ collection.setdefault("license", "")
269
+ collection.setdefault("providers", [])
270
+ collection.setdefault("summaries", {})
271
+ collection.setdefault(
272
+ "extent", {"spatial": {"bbox": []}, "temporal": {"interval": []}}
273
+ )
274
+ collection.setdefault("assets", {})
275
+
276
+ # Determine the structural parent URL
277
+ # When accessed via /catalogs/{id}/collections/{id}, the parent is the catalog
278
+ base_url = str(request.base_url)
279
+ parent_url = f"{base_url}catalogs/{catalog_id}"
280
+
281
+ # Create the collection links using CollectionLinks with catalog as parent
282
+ collection_links = CollectionLinks(
283
+ collection_id=collection_id,
284
+ request=request,
285
+ extensions=extensions,
286
+ parent_url=parent_url,
287
+ ).create_links()
288
+
289
+ # Add any additional links from the collection dictionary
290
+ original_links = collection.get("links")
291
+ if original_links:
292
+ collection_links += resolve_links(original_links, str(request.base_url))
293
+
294
+ # Inject catalog link for consistency (same as parent in this context)
295
+ catalog_link = {
296
+ "rel": "catalog",
297
+ "type": "application/json",
298
+ "href": parent_url,
299
+ "title": catalog_id,
300
+ }
301
+ collection_links.append(catalog_link)
302
+
303
+ collection["links"] = collection_links
304
+
305
+ if get_bool_env("STAC_INDEX_ASSETS"):
306
+ collection["assets"] = {
307
+ a.pop("es_key"): a for a in collection.get("assets", [])
308
+ }
309
+ collection["item_assets"] = {
310
+ i.pop("es_key"): i for i in collection.get("item_assets", [])
311
+ }
312
+
313
+ else:
314
+ collection["assets"] = collection.get("assets", {})
315
+ if item_assets := collection.get("item_assets"):
316
+ collection["item_assets"] = item_assets
317
+
318
+ # Return the stac_types.Collection object
319
+ return stac_types.Collection(**collection)
320
+
321
+
322
+ class CatalogSerializer(Serializer):
323
+ """Serialization methods for STAC catalogs."""
324
+
325
+ @classmethod
326
+ def stac_to_db(cls, catalog: Catalog, request: Request) -> Catalog:
327
+ """
328
+ Transform STAC Catalog to database-ready STAC catalog.
329
+
330
+ Args:
331
+ catalog: the STAC Catalog object to be transformed
332
+ request: the API request
333
+
334
+ Returns:
335
+ Catalog: The database-ready STAC Catalog object.
336
+ """
337
+ catalog = deepcopy(catalog)
338
+ catalog.links = resolve_links(catalog.links, str(request.base_url))
339
+ return catalog
340
+
341
+ @classmethod
342
+ def db_to_stac(
343
+ cls, catalog: dict, request: Request, extensions: Optional[List[str]] = []
344
+ ) -> Catalog:
345
+ """Transform database model to STAC catalog.
346
+
347
+ Args:
348
+ catalog (dict): The catalog data in dictionary form, extracted from the database.
349
+ request (Request): the API request
350
+ extensions: A list of the extension class names (`ext.__name__`) or all enabled STAC API extensions.
351
+
352
+ Returns:
353
+ Catalog: The STAC catalog object.
354
+ """
355
+ # Avoid modifying the input dict in-place
356
+ catalog = deepcopy(catalog)
357
+
358
+ # Set defaults
359
+ catalog.setdefault("type", "Catalog")
360
+ catalog.setdefault("stac_extensions", [])
361
+ catalog.setdefault("stac_version", "")
362
+ catalog.setdefault("title", "")
363
+ catalog.setdefault("description", "")
364
+
365
+ # Create the catalog links - for now, just resolve existing links
366
+ original_links = catalog.get("links", [])
367
+ if original_links:
368
+ catalog["links"] = resolve_links(original_links, str(request.base_url))
369
+ else:
370
+ catalog["links"] = []
371
+
372
+ # Return the Catalog object
373
+ return Catalog(**catalog)
@@ -1,2 +1,2 @@
1
1
  """library version."""
2
- __version__ = "6.7.6"
2
+ __version__ = "6.8.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stac_fastapi_core
3
- Version: 6.7.6
3
+ Version: 6.8.1
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
@@ -44,7 +44,7 @@ Description-Content-Type: text/markdown
44
44
  [![GitHub forks](https://img.shields.io/github/forks/stac-utils/stac-fastapi-elasticsearch-opensearch.svg?color=blue)](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/network/members)
45
45
  [![PyPI version](https://img.shields.io/pypi/v/stac-fastapi-elasticsearch.svg?color=blue)](https://pypi.org/project/stac-fastapi-elasticsearch/)
46
46
  [![STAC](https://img.shields.io/badge/STAC-1.1.0-blue.svg)](https://github.com/radiantearth/stac-spec/tree/v1.1.0)
47
- [![stac-fastapi](https://img.shields.io/badge/stac--fastapi-6.0.0-blue.svg)](https://github.com/stac-utils/stac-fastapi)
47
+ [![stac-fastapi](https://img.shields.io/badge/stac--fastapi-6.1.1-blue.svg)](https://github.com/stac-utils/stac-fastapi)
48
48
 
49
49
  Core functionality for stac-fastapi. For full documentation, please see the [main README](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/blob/main/README.md).
50
50
 
@@ -1,25 +1,27 @@
1
1
  stac_fastapi/core/__init__.py,sha256=8izV3IWRGdXmDOK1hIPQAanbWs9EI04PJCGgqG1ZGIs,20
2
- stac_fastapi/core/base_database_logic.py,sha256=3_XJ_j06ogQHE-Tcjkv5Vye_zNDn9OEU9lNYU03am1k,4618
2
+ stac_fastapi/core/base_database_logic.py,sha256=JL7DRcDdqeaLbSPPGcIUMs7q6I3Gm_E5XCOwFG458Io,6053
3
3
  stac_fastapi/core/base_settings.py,sha256=R3_Sx7n5XpGMs3zAwFJD7y008WvGU_uI2xkaabm82Kg,239
4
4
  stac_fastapi/core/basic_auth.py,sha256=RhFv3RVSHF6OaqnaaU2DO4ncJ_S5nB1q8UNpnVJJsrk,2155
5
- stac_fastapi/core/core.py,sha256=Gf7UUepNTphpSrKWTVwhenaWJyw2YYEVORXVsE2FbYs,50772
5
+ stac_fastapi/core/core.py,sha256=QvwZbXi5PssZXBXxyggLsYHfvzj57NidloI4uVXenzM,52238
6
6
  stac_fastapi/core/datetime_utils.py,sha256=QygF2mJFfI_zqCwmSIec3HYqrsVsn3nUcaRQx3CD7Zw,4683
7
+ stac_fastapi/core/queryables.py,sha256=0gKdxlmCVaIj3ODpmyIfzLChEB1nNKXPZhA3K9ApfL0,3755
7
8
  stac_fastapi/core/rate_limit.py,sha256=Gu8dAaJReGsj1L91U6m2tflU6RahpXDRs2-AYSKoybA,1318
8
9
  stac_fastapi/core/redis_utils.py,sha256=6_lrXfZBi6vCCCibLDdwwHC3lLaXYTEmqQpxOMaCUH4,9689
9
10
  stac_fastapi/core/route_dependencies.py,sha256=hdtuMkv-zY1vg0YxiCz1aKP0SbBcORqDGEKDGgEazW8,5482
10
- stac_fastapi/core/serializers.py,sha256=HPA110RLZ17EnKrFf1rvVu5EwQHZto4V912Ofp_ypjA,7951
11
+ stac_fastapi/core/serializers.py,sha256=l5EWZvlGjlfsZ3S4wHjWKD8sJBf83zd2dkursu18fV4,13345
11
12
  stac_fastapi/core/session.py,sha256=aXqu4LXfVbAAsChMVXd9gAhczA2bZPne6HqPeklAwMY,474
12
13
  stac_fastapi/core/utilities.py,sha256=XR_9afK_j8wCydgoXj-CMtRyI8KqgIL3d4HZOE779dU,7807
13
- stac_fastapi/core/version.py,sha256=orOSypjxHuXOJkA8sMlrI-LUPRWXarswpIi-wr5CKPo,45
14
- stac_fastapi/core/extensions/__init__.py,sha256=zSIAqou8jnakWPbkh4Ddcx1-oazZVBOs7U2PAakAdU0,291
14
+ stac_fastapi/core/version.py,sha256=mmv17WIhpgYJwiq4ovHUKIHbPTRN4ViHSjE63rArov0,45
15
+ stac_fastapi/core/extensions/__init__.py,sha256=oaK-UJDQSEISdQ8VtM0ESxpsv7Hx1HbAdmMnh6MTFD4,356
15
16
  stac_fastapi/core/extensions/aggregation.py,sha256=v1hUHqlYuMqfQ554g3cTp16pUyRYucQxPERbHPAFtf8,1878
17
+ stac_fastapi/core/extensions/catalogs.py,sha256=WwUI4Q20zbD5uN2le12uGnGPmQsoQnpD7bcJ__bi02Y,37963
16
18
  stac_fastapi/core/extensions/collections_search.py,sha256=xpv51nffMq5a8grNSaLbv2IzeI5JH_pqcoWRbWhzn6Y,14406
17
19
  stac_fastapi/core/extensions/fields.py,sha256=NCT5XHvfaf297eDPNaIFsIzvJnbbUTpScqF0otdx0NA,1066
18
20
  stac_fastapi/core/extensions/filter.py,sha256=-NQGME7rR_ereuDx-LAa1M5JhEXFaKiTtkH2asraYHE,2998
19
21
  stac_fastapi/core/extensions/query.py,sha256=Xmo8pfZEZKPudZEjjozv3R0wLOP0ayjC9E67sBOXqWY,1803
20
- stac_fastapi/core/models/__init__.py,sha256=g-D1DiGfmC9Bg27DW9JzkN6fAvscv75wyhyiZ6NzvIk,48
21
- stac_fastapi/core/models/links.py,sha256=0dWSEMt3aa7NCISlHwo11zLBeIV1LwXG3JGjrXC3dZI,6672
22
+ stac_fastapi/core/models/__init__.py,sha256=sUsEB7umGZVYXjT4EHqLwm8p2wevtRBdig2Ioj2ZdVQ,631
23
+ stac_fastapi/core/models/links.py,sha256=5KEZKisFN34U4UuOzSQnDy0QdsUOT2VRuuY36vs-FGw,7074
22
24
  stac_fastapi/core/models/search.py,sha256=7SgAUyzHGXBXSqB4G6cwq9FMwoAS00momb7jvBkjyow,27
23
- stac_fastapi_core-6.7.6.dist-info/METADATA,sha256=9RkFcWG0XA-kvFl6MvSAdEGYG7WKMWhzZPLMrEtVcQM,3480
24
- stac_fastapi_core-6.7.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
25
- stac_fastapi_core-6.7.6.dist-info/RECORD,,
25
+ stac_fastapi_core-6.8.1.dist-info/METADATA,sha256=GB6GOulxa_ja31Bf1iRvzhCsGDPOVz44zBun5xuL_RE,3480
26
+ stac_fastapi_core-6.8.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
27
+ stac_fastapi_core-6.8.1.dist-info/RECORD,,