From e0bd94f3cf6dfdbaaf02ae32d331e26e7c58ab3f Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 29 Aug 2024 14:46:09 +0100 Subject: [PATCH 01/38] Adding patch endpoints to transactions extension to elasticsearch. --- .../stac_fastapi/core/base_database_logic.py | 48 +++- stac_fastapi/core/stac_fastapi/core/core.py | 106 ++++++++ .../core/stac_fastapi/core/utilities.py | 63 +++++ .../elasticsearch/database_logic.py | 226 +++++++++++++++++- 4 files changed, 440 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/base_database_logic.py b/stac_fastapi/core/stac_fastapi/core/base_database_logic.py index 0043cfb8..50d2062c 100644 --- a/stac_fastapi/core/stac_fastapi/core/base_database_logic.py +++ b/stac_fastapi/core/stac_fastapi/core/base_database_logic.py @@ -1,7 +1,7 @@ """Base database logic.""" import abc -from typing import Any, Dict, Iterable, Optional +from typing import Any, Dict, Iterable, List, Optional class BaseDatabaseLogic(abc.ABC): @@ -29,6 +29,30 @@ async def create_item(self, item: Dict, refresh: bool = False) -> None: """Create an item in the database.""" pass + @abc.abstractmethod + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: Dict, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a item in the database follows RF7396.""" + pass + + @abc.abstractmethod + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a item in the database follows RF6902.""" + pass + @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False @@ -41,6 +65,28 @@ async def create_collection(self, collection: Dict, refresh: bool = False) -> No """Create a collection in the database.""" pass + @abc.abstractmethod + async def merge_patch_collection( + self, + collection_id: str, + collection: Dict, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a collection in the database follows RF7396.""" + pass + + @abc.abstractmethod + async def json_patch_collection( + self, + collection_id: str, + operations: List, + base_url: str, + refresh: bool = True, + ) -> Dict: + """Patch a collection in the database follows RF6902.""" + pass + @abc.abstractmethod async def find_collection(self, collection_id: str) -> Dict: """Find a collection in the database.""" diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 57f7c816..b390356d 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -1,4 +1,5 @@ """Core client.""" + import logging import re from datetime import datetime as datetime_type @@ -708,6 +709,58 @@ async def update_item( return ItemSerializer.db_to_stac(item, base_url) + @overrides + async def merge_patch_item( + self, collection_id: str, item_id: str, item: stac_types.PartialItem, **kwargs + ) -> Optional[stac_types.Item]: + """Patch an item in the collection following RF7396.. + + Args: + collection_id (str): The ID of the collection the item belongs to. + item_id (str): The ID of the item to be updated. + item (stac_types.PartialItem): The partial item data. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Item: The patched item object. + + """ + item = await self.database.merge_patch_item( + collection_id=collection_id, + item_id=item_id, + item=item, + base_url=str(kwargs["request"].base_url), + ) + return ItemSerializer.db_to_stac(item, base_url=str(kwargs["request"].base_url)) + + @overrides + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[stac_types.PatchOperation], + **kwargs, + ) -> Optional[stac_types.Item]: + """Patch an item in the collection following RF6902. + + Args: + collection_id (str): The ID of the collection the item belongs to. + item_id (str): The ID of the item to be updated. + operations (List): List of operations to run on item. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Item: The patched item object. + + """ + item = await self.database.json_patch_item( + collection_id=collection_id, + item_id=item_id, + base_url=str(kwargs["request"].base_url), + operations=operations, + ) + return ItemSerializer.db_to_stac(item, base_url=str(kwargs["request"].base_url)) + @overrides async def delete_item( self, item_id: str, collection_id: str, **kwargs @@ -788,6 +841,59 @@ async def update_collection( extensions=[type(ext).__name__ for ext in self.database.extensions], ) + @overrides + async def merge_patch_collection( + self, collection_id: str, collection: stac_types.PartialCollection, **kwargs + ) -> Optional[stac_types.Collection]: + """Patch a collection following RF7396.. + + Args: + collection_id (str): The ID of the collection to patch. + collection (stac_types.Collection): The partial collection data. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Collection: The patched collection object. + + """ + collection = await self.database.merge_patch_collection( + collection_id=collection_id, + base_url=str(kwargs["request"].base_url), + collection=collection, + ) + + return CollectionSerializer.db_to_stac( + collection, + kwargs["request"], + extensions=[type(ext).__name__ for ext in self.database.extensions], + ) + + @overrides + async def json_patch_collection( + self, collection_id: str, operations: List[stac_types.PatchOperation], **kwargs + ) -> Optional[stac_types.Collection]: + """Patch a collection following RF6902. + + Args: + collection_id (str): The ID of the collection to patch. + operations (List): List of operations to run on collection. + kwargs: Other optional arguments, including the request object. + + Returns: + stac_types.Collection: The patched collection object. + + """ + collection = await self.database.json_patch_collection( + collection_id=collection_id, + operations=operations, + base_url=str(kwargs["request"].base_url), + ) + return CollectionSerializer.db_to_stac( + collection, + kwargs["request"], + extensions=[type(ext).__name__ for ext in self.database.extensions], + ) + @overrides async def delete_collection( self, collection_id: str, **kwargs diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index d8c69529..96880d68 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -3,6 +3,8 @@ This module contains functions for transforming geospatial coordinates, such as converting bounding boxes to polygon representations. """ + +import json from typing import Any, Dict, List, Optional, Set, Union from stac_fastapi.types.stac import Item @@ -133,3 +135,64 @@ def dict_deep_update(merge_to: Dict[str, Any], merge_from: Dict[str, Any]) -> No dict_deep_update(merge_to[k], merge_from[k]) else: merge_to[k] = v + + +def merge_to_operations(data: Dict) -> List: + """Convert merge operation to list of RF6902 operations. + + Args: + data: dictionary to convert. + + Returns: + List: list of RF6902 operations. + """ + operations = [] + + for key, value in data.copy().items(): + + if value is None: + operations.append({"op": "remove", "path": key}) + continue + + elif isinstance(value, dict): + nested_operations = merge_to_operations(value) + + for nested_operation in nested_operations: + nested_operation["path"] = f"{key}.{nested_operation['path']}" + operations.append(nested_operation) + + else: + operations.append({"op": "add", "path": key, "value": value}) + + return operations + + +def operations_to_script(operations: List) -> Dict: + """Convert list of operation to painless script. + + Args: + operations: List of RF6902 operations. + + Returns: + Dict: elasticsearch update script. + """ + source = "" + for operation in operations: + if operation["op"] in ["copy", "move"]: + source += ( + f"ctx._source.{operation['path']} = ctx._source.{operation['from']};" + ) + + if operation["op"] in ["remove", "move"]: + nest, partition, key = operation["path"].rpartition(".") + source += f"ctx._source.{nest + partition}remove('{key}');" + + if operation["op"] in ["add", "replace"]: + source += ( + f"ctx._source.{operation['path']} = {json.dumps(operation['value'])};" + ) + + return { + "source": source, + "lang": "painless", + } diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index a4b40325..6de5c424 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -1,4 +1,5 @@ """Database logic.""" + import asyncio import logging import os @@ -12,13 +13,24 @@ from elasticsearch import exceptions, helpers # type: ignore from stac_fastapi.core.extensions import filter from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer -from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon +from stac_fastapi.core.utilities import ( + MAX_LIMIT, + bbox2polygon, + merge_to_operations, + operations_to_script, +) from stac_fastapi.elasticsearch.config import AsyncElasticsearchSettings from stac_fastapi.elasticsearch.config import ( ElasticsearchSettings as SyncElasticsearchSettings, ) from stac_fastapi.types.errors import ConflictError, NotFoundError -from stac_fastapi.types.stac import Collection, Item +from stac_fastapi.types.stac import ( + Collection, + Item, + PartialCollection, + PartialItem, + PatchOperation, +) logger = logging.getLogger(__name__) @@ -735,6 +747,129 @@ async def create_item(self, item: Item, refresh: bool = False): f"Item {item_id} in collection {collection_id} already exists" ) + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: PartialItem, + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for merge patching an item following RF7396. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + item (PartialItem): The partial item to be updated. + base_url: (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Raises: + ConflictError: If the item already exists in the database. + + Returns: + None + """ + operations = merge_to_operations(item) + + return await self.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for json patching an item following RF6902. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Raises: + ConflictError: If the item already exists in the database. + + Returns: + None + """ + new_item_id = None + new_collection_id = None + script_operations = [] + + for operation in operations: + if operation["op"] in ["add", "replace"]: + if ( + operation["path"] == "collection" + and collection_id != operation["value"] + ): + await self.check_collection_exists(collection_id=operation["value"]) + new_collection_id = operation["value"] + + if operation["path"] == "id" and item_id != operation["value"]: + new_item_id = operation["value"] + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + if not new_collection_id and not new_item_id: + await self.client.update( + index=index_by_collection_id(collection_id), + id=mk_item_id(item_id, collection_id), + script=script, + refresh=refresh, + ) + + if new_collection_id: + await self.client.reindex( + body={ + "dest": {"index": f"{ITEMS_INDEX_PREFIX}{operation['value']}"}, + "source": { + "index": f"{ITEMS_INDEX_PREFIX}{collection_id}", + "query": {"term": {"id": {"value": item_id}}}, + }, + "script": { + "lang": "painless", + "source": ( + f"""ctx._id = ctx._id.replace('{collection_id}', '{operation["value"]}');""" + f"""ctx._source.collection = '{operation["value"]}';""" + + script + ), + }, + }, + wait_for_completion=True, + refresh=False, + ) + + item = await self.get_one_item(collection_id, item_id) + + if new_item_id: + item["id"] = new_item_id + item = await self.prep_create_item(item=item, base_url=base_url) + await self.create_item(item=item, refresh=False) + + if new_item_id or new_collection_id: + + await self.delete_item( + item_id=item_id, + collection_id=collection_id, + refresh=refresh, + ) + + return item + async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False ): @@ -859,6 +994,93 @@ async def update_collection( refresh=refresh, ) + async def merge_patch_collection( + self, + collection_id: str, + collection: PartialCollection, + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for merge patching a collection following RF7396. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + item (PartialItem): The partial item to be updated. + base_url: (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Raises: + ConflictError: If the item already exists in the database. + + Returns: + None + """ + operations = merge_to_operations(collection) + + return await self.json_patch_collection( + collection_id=collection_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_collection( + self, + collection_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for json patching an item following RF6902. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Raises: + ConflictError: If the item already exists in the database. + + Returns: + None + """ + new_collection_id = None + script_operations = [] + + for operation in operations: + if ( + operation["op"] in ["add", "replace"] + and operation["path"] == "collection" + and collection_id != operation["value"] + ): + new_collection_id = operation["value"] + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + if not new_collection_id: + await self.client.update( + index=COLLECTIONS_INDEX, + id=collection_id, + script=script, + refresh=refresh, + ) + + collection = await self.find_collection(collection_id) + + if new_collection_id: + collection["id"] = new_collection_id + await self.update_collection( + collection_id=collection_id, collection=collection, refresh=False + ) + + return collection + async def delete_collection(self, collection_id: str, refresh: bool = False): """Delete a collection from the database. From 01c15630722c2fcfc6bbc30f4f2f02fd6ff61ad7 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 29 Aug 2024 14:59:23 +0100 Subject: [PATCH 02/38] Adding patch to opensearch backend. --- .../elasticsearch/database_logic.py | 39 ++-- .../stac_fastapi/opensearch/database_logic.py | 215 +++++++++++++++++- 2 files changed, 227 insertions(+), 27 deletions(-) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 6de5c424..a0cb5698 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -24,6 +24,7 @@ ElasticsearchSettings as SyncElasticsearchSettings, ) from stac_fastapi.types.errors import ConflictError, NotFoundError +from stac_fastapi.types.links import resolve_links from stac_fastapi.types.stac import ( Collection, Item, @@ -764,11 +765,8 @@ async def merge_patch_item( base_url: (str): The base URL used for constructing URLs for the item. refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. - Raises: - ConflictError: If the item already exists in the database. - Returns: - None + patched item. """ operations = merge_to_operations(item) @@ -797,11 +795,8 @@ async def json_patch_item( base_url (str): The base URL used for constructing URLs for the item. refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. - Raises: - ConflictError: If the item already exists in the database. - Returns: - None + patched item. """ new_item_id = None new_collection_id = None @@ -1000,21 +995,18 @@ async def merge_patch_collection( collection: PartialCollection, base_url: str, refresh: bool = True, - ) -> Item: + ) -> Collection: """Database logic for merge patching a collection following RF7396. Args: - collection_id(str): Collection that item belongs to. - item_id(str): Id of item to be patched. - item (PartialItem): The partial item to be updated. - base_url: (str): The base URL used for constructing URLs for the item. + collection_id(str): Id of collection to be patched. + collection (PartialCollection): The partial collection to be updated. + base_url: (str): The base URL used for constructing links. refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. - Raises: - ConflictError: If the item already exists in the database. Returns: - None + patched collection. """ operations = merge_to_operations(collection) @@ -1031,21 +1023,17 @@ async def json_patch_collection( operations: List[PatchOperation], base_url: str, refresh: bool = True, - ) -> Item: - """Database logic for json patching an item following RF6902. + ) -> Collection: + """Database logic for json patching a collection following RF6902. Args: - collection_id(str): Collection that item belongs to. - item_id(str): Id of item to be patched. + collection_id(str): Id of collection to be patched. operations (list): List of operations to run. - base_url (str): The base URL used for constructing URLs for the item. + base_url (str): The base URL used for constructing links. refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. - Raises: - ConflictError: If the item already exists in the database. - Returns: - None + patched collection. """ new_collection_id = None script_operations = [] @@ -1075,6 +1063,7 @@ async def json_patch_collection( if new_collection_id: collection["id"] = new_collection_id + collection["links"] = resolve_links([], base_url) await self.update_collection( collection_id=collection_id, collection=collection, refresh=False ) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 841d5e27..b45370fd 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1,4 +1,5 @@ """Database logic.""" + import asyncio import logging import os @@ -14,13 +15,25 @@ from stac_fastapi.core import serializers from stac_fastapi.core.extensions import filter -from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon +from stac_fastapi.core.utilities import ( + MAX_LIMIT, + bbox2polygon, + merge_to_operations, + operations_to_script, +) from stac_fastapi.opensearch.config import ( AsyncOpensearchSettings as AsyncSearchSettings, ) from stac_fastapi.opensearch.config import OpensearchSettings as SyncSearchSettings from stac_fastapi.types.errors import ConflictError, NotFoundError -from stac_fastapi.types.stac import Collection, Item +from stac_fastapi.types.links import resolve_links +from stac_fastapi.types.stac import ( + Collection, + Item, + PartialCollection, + PartialItem, + PatchOperation, +) logger = logging.getLogger(__name__) @@ -767,6 +780,123 @@ async def create_item(self, item: Item, refresh: bool = False): f"Item {item_id} in collection {collection_id} already exists" ) + async def merge_patch_item( + self, + collection_id: str, + item_id: str, + item: PartialItem, + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for merge patching an item following RF7396. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + item (PartialItem): The partial item to be updated. + base_url: (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched item. + """ + operations = merge_to_operations(item) + + return await self.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_item( + self, + collection_id: str, + item_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Item: + """Database logic for json patching an item following RF6902. + + Args: + collection_id(str): Collection that item belongs to. + item_id(str): Id of item to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing URLs for the item. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched item. + """ + new_item_id = None + new_collection_id = None + script_operations = [] + + for operation in operations: + if operation["op"] in ["add", "replace"]: + if ( + operation["path"] == "collection" + and collection_id != operation["value"] + ): + await self.check_collection_exists(collection_id=operation["value"]) + new_collection_id = operation["value"] + + if operation["path"] == "id" and item_id != operation["value"]: + new_item_id = operation["value"] + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + if not new_collection_id and not new_item_id: + await self.client.update( + index=index_by_collection_id(collection_id), + id=mk_item_id(item_id, collection_id), + script=script, + refresh=refresh, + ) + + if new_collection_id: + await self.client.reindex( + body={ + "dest": {"index": f"{ITEMS_INDEX_PREFIX}{operation['value']}"}, + "source": { + "index": f"{ITEMS_INDEX_PREFIX}{collection_id}", + "query": {"term": {"id": {"value": item_id}}}, + }, + "script": { + "lang": "painless", + "source": ( + f"""ctx._id = ctx._id.replace('{collection_id}', '{operation["value"]}');""" + f"""ctx._source.collection = '{operation["value"]}';""" + + script + ), + }, + }, + wait_for_completion=True, + refresh=False, + ) + + item = await self.get_one_item(collection_id, item_id) + + if new_item_id: + item["id"] = new_item_id + item = await self.prep_create_item(item=item, base_url=base_url) + await self.create_item(item=item, refresh=False) + + if new_item_id or new_collection_id: + + await self.delete_item( + item_id=item_id, + collection_id=collection_id, + refresh=refresh, + ) + + return item + async def delete_item( self, item_id: str, collection_id: str, refresh: bool = False ): @@ -891,6 +1021,87 @@ async def update_collection( refresh=refresh, ) + async def merge_patch_collection( + self, + collection_id: str, + collection: PartialCollection, + base_url: str, + refresh: bool = True, + ) -> Collection: + """Database logic for merge patching a collection following RF7396. + + Args: + collection_id(str): Id of collection to be patched. + collection (PartialCollection): The partial collection to be updated. + base_url: (str): The base URL used for constructing links. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + + Returns: + patched collection. + """ + operations = merge_to_operations(collection) + + return await self.json_patch_collection( + collection_id=collection_id, + operations=operations, + base_url=base_url, + refresh=refresh, + ) + + async def json_patch_collection( + self, + collection_id: str, + operations: List[PatchOperation], + base_url: str, + refresh: bool = True, + ) -> Collection: + """Database logic for json patching a collection following RF6902. + + Args: + collection_id(str): Id of collection to be patched. + operations (list): List of operations to run. + base_url (str): The base URL used for constructing links. + refresh (bool, optional): Refresh the index after performing the operation. Defaults to True. + + Returns: + patched collection. + """ + new_collection_id = None + script_operations = [] + + for operation in operations: + if ( + operation["op"] in ["add", "replace"] + and operation["path"] == "collection" + and collection_id != operation["value"] + ): + new_collection_id = operation["value"] + + else: + script_operations.append(operation) + + script = operations_to_script(script_operations) + + if not new_collection_id: + await self.client.update( + index=COLLECTIONS_INDEX, + id=collection_id, + script=script, + refresh=refresh, + ) + + collection = await self.find_collection(collection_id) + + if new_collection_id: + collection["id"] = new_collection_id + collection["links"] = resolve_links([], base_url) + await self.update_collection( + collection_id=collection_id, collection=collection, refresh=False + ) + + return collection + async def delete_collection(self, collection_id: str, refresh: bool = False): """Delete a collection from the database. From d171c245f709e65c79c778b413fb383f8f0c4282 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Fri, 30 Aug 2024 12:04:39 +0100 Subject: [PATCH 03/38] Pinning to pull request version for tests. --- dockerfiles/Dockerfile.dev.es | 2 +- dockerfiles/Dockerfile.dev.os | 3 ++- stac_fastapi/core/setup.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/dockerfiles/Dockerfile.dev.es b/dockerfiles/Dockerfile.dev.es index 009f9681..f0516031 100644 --- a/dockerfiles/Dockerfile.dev.es +++ b/dockerfiles/Dockerfile.dev.es @@ -4,7 +4,7 @@ FROM python:3.10-slim # update apt pkgs, and install build-essential for ciso8601 RUN apt-get update && \ apt-get -y upgrade && \ - apt-get install -y build-essential git && \ + apt-get -y install build-essential git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* diff --git a/dockerfiles/Dockerfile.dev.os b/dockerfiles/Dockerfile.dev.os index d9dc8b0a..d6488a74 100644 --- a/dockerfiles/Dockerfile.dev.os +++ b/dockerfiles/Dockerfile.dev.os @@ -4,10 +4,11 @@ FROM python:3.10-slim # update apt pkgs, and install build-essential for ciso8601 RUN apt-get update && \ apt-get -y upgrade && \ - apt-get install -y build-essential && \ + apt-get -y install build-essential && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +RUN apt-get -y install git # update certs used by Requests ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt diff --git a/stac_fastapi/core/setup.py b/stac_fastapi/core/setup.py index bb894b9b..3043bdd6 100644 --- a/stac_fastapi/core/setup.py +++ b/stac_fastapi/core/setup.py @@ -10,9 +10,9 @@ "attrs>=23.2.0", "pydantic[dotenv]", "stac_pydantic>=3", - "stac-fastapi.types==3.0.0a4", - "stac-fastapi.api==3.0.0a4", - "stac-fastapi.extensions==3.0.0a4", + "stac-fastapi.types@git+https://github.com/stac-utils/stac-fastapi.git@refs/pull/744/head#subdirectory=stac_fastapi/types", + "stac-fastapi.api@git+https://github.com/stac-utils/stac-fastapi.git@refs/pull/744/head#subdirectory=stac_fastapi/api", + "stac-fastapi.extensions@git+https://github.com/stac-utils/stac-fastapi.git@refs/pull/744/head#subdirectory=stac_fastapi/extensions", "orjson", "overrides", "geojson-pydantic", From 5400e4221395a1f23b00097070912c9edbd530b1 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 16 Sep 2024 16:29:03 +0100 Subject: [PATCH 04/38] Updating patch types. --- .../core/stac_fastapi/core/utilities.py | 25 ++++++++----------- .../elasticsearch/database_logic.py | 22 ++++++++-------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 96880d68..ba4c6739 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -7,7 +7,7 @@ import json from typing import Any, Dict, List, Optional, Set, Union -from stac_fastapi.types.stac import Item +from stac_fastapi.types.stac import Item, PatchAddReplaceTest, PatchRemove MAX_LIMIT = 10000 @@ -151,18 +151,17 @@ def merge_to_operations(data: Dict) -> List: for key, value in data.copy().items(): if value is None: - operations.append({"op": "remove", "path": key}) - continue + operations.append(PatchRemove(op="remove", path=key)) elif isinstance(value, dict): nested_operations = merge_to_operations(value) for nested_operation in nested_operations: - nested_operation["path"] = f"{key}.{nested_operation['path']}" + nested_operation.path = f"{key}.{nested_operation.path}" operations.append(nested_operation) else: - operations.append({"op": "add", "path": key, "value": value}) + operations.append(PatchAddReplaceTest(op="add", path=key, value=value)) return operations @@ -178,19 +177,15 @@ def operations_to_script(operations: List) -> Dict: """ source = "" for operation in operations: - if operation["op"] in ["copy", "move"]: - source += ( - f"ctx._source.{operation['path']} = ctx._source.{operation['from']};" - ) + if operation.op in ["copy", "move"]: + source += f"ctx._source.{operation.path} = ctx._source.{getattr(operation, 'from')};" - if operation["op"] in ["remove", "move"]: - nest, partition, key = operation["path"].rpartition(".") + if operation.op in ["remove", "move"]: + nest, partition, key = operation.path.rpartition(".") source += f"ctx._source.{nest + partition}remove('{key}');" - if operation["op"] in ["add", "replace"]: - source += ( - f"ctx._source.{operation['path']} = {json.dumps(operation['value'])};" - ) + if operation.op in ["add", "replace"]: + source += f"ctx._source.{operation.path} = {json.dumps(operation.value)};" return { "source": source, diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 41783dc6..50e9bb15 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -930,16 +930,16 @@ async def json_patch_item( script_operations = [] for operation in operations: - if operation["op"] in ["add", "replace"]: - if ( - operation["path"] == "collection" - and collection_id != operation["value"] - ): - await self.check_collection_exists(collection_id=operation["value"]) - new_collection_id = operation["value"] + if operation.path in [ + "collection", + "id", + ] and operation.op in ["add", "replace"]: + if operation.path == "collection" and collection_id != operation.value: + await self.check_collection_exists(collection_id=operation.value) + new_collection_id = operation.value - if operation["path"] == "id" and item_id != operation["value"]: - new_item_id = operation["value"] + if operation.path == "id" and item_id != operation.value: + new_item_id = operation.value else: script_operations.append(operation) @@ -1167,8 +1167,8 @@ async def json_patch_collection( for operation in operations: if ( - operation["op"] in ["add", "replace"] - and operation["path"] == "collection" + operation.get("op") in ["add", "replace"] + and operation.get("path") == "collection" and collection_id != operation["value"] ): new_collection_id = operation["value"] From fe8530a3d350fd30137c297efb38d58d1025807c Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Tue, 17 Sep 2024 09:05:19 +0100 Subject: [PATCH 05/38] Adding checks for existing properties. --- stac_fastapi/core/stac_fastapi/core/utilities.py | 8 +++++++- .../stac_fastapi/elasticsearch/database_logic.py | 15 +++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index ba4c6739..bf2e970b 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -177,11 +177,17 @@ def operations_to_script(operations: List) -> Dict: """ source = "" for operation in operations: + nest, partition, key = operation.path.rpartition(".") + if nest: + source += f"if (!ctx._source.containsKey('{nest}')){{Debug.explain('{nest} does not exist');}}" + + if operation.op != "add": + source += f"if (!ctx._source.{nest + partition}containsKey('{key}')){{Debug.explain('{operation.path} does not exist');}}" + if operation.op in ["copy", "move"]: source += f"ctx._source.{operation.path} = ctx._source.{getattr(operation, 'from')};" if operation.op in ["remove", "move"]: - nest, partition, key = operation.path.rpartition(".") source += f"ctx._source.{nest + partition}remove('{key}');" if operation.op in ["add", "replace"]: diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 50e9bb15..9e2cae51 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -947,12 +947,15 @@ async def json_patch_item( script = operations_to_script(script_operations) if not new_collection_id and not new_item_id: - await self.client.update( - index=index_by_collection_id(collection_id), - id=mk_item_id(item_id, collection_id), - script=script, - refresh=refresh, - ) + try: + await self.client.update( + index=index_by_collection_id(collection_id), + id=mk_item_id(item_id, collection_id), + script=script, + refresh=refresh, + ) + except exceptions.BadRequestError as e: + raise KeyError(f"{e.info['error']['caused_by']['to_string']}") if new_collection_id: await self.client.reindex( From 7cf36eb3efe4a3358a7367503b5484e175b5c548 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Tue, 19 Nov 2024 14:56:05 +0000 Subject: [PATCH 06/38] Updating utils. --- dockerfiles/Dockerfile.deploy.es | 3 + .../core/stac_fastapi/core/utilities.py | 91 +++++++++++++++++-- .../elasticsearch/database_logic.py | 2 +- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/dockerfiles/Dockerfile.deploy.es b/dockerfiles/Dockerfile.deploy.es index 2eab7b9d..ef87d1c4 100644 --- a/dockerfiles/Dockerfile.deploy.es +++ b/dockerfiles/Dockerfile.deploy.es @@ -3,9 +3,12 @@ FROM python:3.10-slim RUN apt-get update && \ apt-get -y upgrade && \ apt-get -y install gcc && \ + apt-get -y install build-essential git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* + + ENV CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt WORKDIR /app diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index bf2e970b..5caaba17 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -166,6 +166,47 @@ def merge_to_operations(data: Dict) -> List: return operations +def split_json_path(path: str) -> OperationPath: + """Split a JSON path into it's components. + + Args: + path: JSON path. + + Returns: + Tuple: nest, partition, key. + """ + path = ( + path[1:].replace("/", ".") if path.startswith("/") else path.replace("/", ".") + ) + nest, partition, key = path.rpartition(".") + + try: + index = int(key) + path = f"{nest}[{index}]" + nest, partition, key = nest.rpartition(".") + + except ValueError: + index = None + + return { + "path": path, + "nest": nest, + "partition": partition, + "key": key, + "index": index, + } + + +def script_checks(source, op, path) -> Dict: + if path["nest"]: + source += f"if (!ctx._source.containsKey('{path['nest']}')){{Debug.explain('{path['nest']} does not exist');}}" + + if path["index"] or op != "add": + source += f"if (!ctx._source.{path['nest'] + path['partition']}containsKey('{path['key']}')){{Debug.explain('{path['path']} does not exist');}}" + + return source + + def operations_to_script(operations: List) -> Dict: """Convert list of operation to painless script. @@ -177,21 +218,55 @@ def operations_to_script(operations: List) -> Dict: """ source = "" for operation in operations: - nest, partition, key = operation.path.rpartition(".") - if nest: - source += f"if (!ctx._source.containsKey('{nest}')){{Debug.explain('{nest} does not exist');}}" + op_path = split_json_path(operation.path) + + if hasattr(operation, "from"): + from_path = split_json_path(getattr(operation, "from")) + source = script_checks(source, operation.op, op_path) + if from_path["index"]: + from_key = from_path["nest"] + from_path["partition"] + from_path["key"] + source += ( + f"if ((ctx._source.{from_key} instanceof ArrayList && ctx._source.{from_key}.size() < {from_path['index']})" + f"|| (!ctx._source.{from_key + from_path['partition']}containsKey('{from_path['index']}'))" + f"{{Debug.explain('{from_path['path']} does not exist');}}" + ) - if operation.op != "add": - source += f"if (!ctx._source.{nest + partition}containsKey('{key}')){{Debug.explain('{operation.path} does not exist');}}" + source = script_checks(source, operation.op, op_path) if operation.op in ["copy", "move"]: - source += f"ctx._source.{operation.path} = ctx._source.{getattr(operation, 'from')};" + if op_path["index"]: + source += ( + f"if (ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key']} instanceof ArrayList)" + f"{{ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key'] + op_path['partition']}add({op_path['index']}, {from_path['path']})}}" + f"else{{ctx._source.{op_path['path']} = {from_path['path']}}}" + ) + + else: + source += ( + f"ctx._source.{op_path['path']} = ctx._source.{from_path['path']};" + ) if operation.op in ["remove", "move"]: - source += f"ctx._source.{nest + partition}remove('{key}');" + remove_path = from_path if operation.op == "move" else op_path + + if remove_path["index"]: + source += f"ctx._source.{remove_path['nest'] + remove_path['partition'] + remove_path['key'] + remove_path['partition']}remove('{remove_path['index']}');" + + else: + source += f"ctx._source.{remove_path['nest'] + remove_path['partition']}remove('{remove_path['key']}');" if operation.op in ["add", "replace"]: - source += f"ctx._source.{operation.path} = {json.dumps(operation.value)};" + if op_path["index"]: + source += ( + f"if (ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key']} instanceof ArrayList)" + f"{{ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key'] + op_path['partition']}add({op_path['index']}, {json.dumps(operation.value)})}}" + f"else{{ctx._source.{op_path['path']} = {json.dumps(operation.value)}}}" + ) + + else: + source += ( + f"ctx._source.{op_path['path']} = {json.dumps(operation.value)};" + ) return { "source": source, diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 9e2cae51..d973efef 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -955,7 +955,7 @@ async def json_patch_item( refresh=refresh, ) except exceptions.BadRequestError as e: - raise KeyError(f"{e.info['error']['caused_by']['to_string']}") + raise KeyError(e.info["error"]["caused_by"]["to_string"]) if new_collection_id: await self.client.reindex( From 5929b002283a9223d60741922c89d0725efbe254 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 09:21:07 +0000 Subject: [PATCH 07/38] Adding model for path. --- stac_fastapi/core/stac_fastapi/core/core.py | 14 +- .../core/stac_fastapi/core/models/patch.py | 35 ++++ .../core/stac_fastapi/core/utilities.py | 110 ++++-------- .../elasticsearch/database_logic.py | 166 +++++++----------- 4 files changed, 143 insertions(+), 182 deletions(-) create mode 100644 stac_fastapi/core/stac_fastapi/core/models/patch.py diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 38d3858e..870f6142 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -377,7 +377,7 @@ async def get_item( @staticmethod def _return_date( - interval: Optional[Union[DateTimeType, str]] + interval: Optional[Union[DateTimeType, str]], ) -> Dict[str, Optional[str]]: """ Convert a date interval. @@ -738,13 +738,15 @@ async def merge_patch_item( stac_types.Item: The patched item object. """ + base_url = str(kwargs["request"].base_url) + item = await self.database.merge_patch_item( collection_id=collection_id, item_id=item_id, item=item, - base_url=str(kwargs["request"].base_url), + base_url=base_url, ) - return ItemSerializer.db_to_stac(item, base_url=str(kwargs["request"].base_url)) + return ItemSerializer.db_to_stac(item, base_url=base_url) @overrides async def json_patch_item( @@ -766,13 +768,15 @@ async def json_patch_item( stac_types.Item: The patched item object. """ + base_url = str(kwargs["request"].base_url) + item = await self.database.json_patch_item( collection_id=collection_id, item_id=item_id, - base_url=str(kwargs["request"].base_url), + base_url=base_url, operations=operations, ) - return ItemSerializer.db_to_stac(item, base_url=str(kwargs["request"].base_url)) + return ItemSerializer.db_to_stac(item, base_url=base_url) @overrides async def delete_item( diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py new file mode 100644 index 00000000..74135992 --- /dev/null +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -0,0 +1,35 @@ +"""patch helpers.""" + +from typing import Optional + +from pydantic import BaseModel, computed_field + + +class ElasticPath(BaseModel): + """Converts a JSON path to an Elasticsearch path. + + Args: + path (str): JSON path to be converted. + + """ + + path: str + nest: Optional[str] = None + partition: Optional[str] = None + key: Optional[str] = None + index: Optional[int] = None + + def __init__(self, *, path: str): + self.path = path.lstrip("/").replace("/", ".") + + self.nest, self.partition, self.key = path.rpartition(".") + + if self.key.isdigit(): + self.index = int(self.key) + self.path = f"{self.nest}[{self.index}]" + self.nest, self.partition, self.key = self.nest.rpartition(".") + + @computed_field + @property + def location(self): + return self.nest + self.partition + self.key diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 5caaba17..4c4ea920 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -4,9 +4,9 @@ such as converting bounding boxes to polygon representations. """ -import json from typing import Any, Dict, List, Optional, Set, Union +from stac_fastapi.core.models.patch import ElasticPath from stac_fastapi.types.stac import Item, PatchAddReplaceTest, PatchRemove MAX_LIMIT = 10000 @@ -45,9 +45,7 @@ def filter_fields( # noqa: C901 return item # Build a shallow copy of included fields on an item, or a sub-tree of an item - def include_fields( - source: Dict[str, Any], fields: Optional[Set[str]] - ) -> Dict[str, Any]: + def include_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> Dict[str, Any]: if not fields: return source @@ -60,9 +58,7 @@ def include_fields( # The root of this key path on the item is a dict, and the # key path indicates a sub-key to be included. Walk the dict # from the root key and get the full nested value to include. - value = include_fields( - source[key_root], fields={".".join(key_path_parts[1:])} - ) + value = include_fields(source[key_root], fields={".".join(key_path_parts[1:])}) if isinstance(clean_item.get(key_root), dict): # A previously specified key and sub-keys may have been included @@ -93,9 +89,7 @@ def exclude_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> None: if key_root in source: if isinstance(source[key_root], dict) and len(key_path_part) > 1: # Walk the nested path of this key to remove the leaf-key - exclude_fields( - source[key_root], fields={".".join(key_path_part[1:])} - ) + exclude_fields(source[key_root], fields={".".join(key_path_part[1:])}) # If, after removing the leaf-key, the root is now an empty # dict, remove it entirely if not source[key_root]: @@ -127,11 +121,7 @@ def dict_deep_update(merge_to: Dict[str, Any], merge_from: Dict[str, Any]) -> No merge_from values take precedence over existing values in merge_to. """ for k, v in merge_from.items(): - if ( - k in merge_to - and isinstance(merge_to[k], dict) - and isinstance(merge_from[k], dict) - ): + if k in merge_to and isinstance(merge_to[k], dict) and isinstance(merge_from[k], dict): dict_deep_update(merge_to[k], merge_from[k]) else: merge_to[k] = v @@ -166,43 +156,25 @@ def merge_to_operations(data: Dict) -> List: return operations -def split_json_path(path: str) -> OperationPath: - """Split a JSON path into it's components. +def add_script_checks(source: str, op: str, path: ElasticPath) -> str: + """Add Elasticsearch checks to operation. Args: - path: JSON path. + source (str): current source of Elasticsearch script + op (str): the operation of script + path (Dict): path of variable to run operation on Returns: - Tuple: nest, partition, key. + Dict: update source of Elasticsearch script """ - path = ( - path[1:].replace("/", ".") if path.startswith("/") else path.replace("/", ".") - ) - nest, partition, key = path.rpartition(".") + if path.nest: + source += f"if (!ctx._source.containsKey('{path.nest}'))" f"{{Debug.explain('{path.nest} does not exist');}}" - try: - index = int(key) - path = f"{nest}[{index}]" - nest, partition, key = nest.rpartition(".") - - except ValueError: - index = None - - return { - "path": path, - "nest": nest, - "partition": partition, - "key": key, - "index": index, - } - - -def script_checks(source, op, path) -> Dict: - if path["nest"]: - source += f"if (!ctx._source.containsKey('{path['nest']}')){{Debug.explain('{path['nest']} does not exist');}}" - - if path["index"] or op != "add": - source += f"if (!ctx._source.{path['nest'] + path['partition']}containsKey('{path['key']}')){{Debug.explain('{path['path']} does not exist');}}" + if path.index or op != "add": + source += ( + f"if (!ctx._source.{path.nest}.containsKey('{path.key}'))" + f"{{Debug.explain('{path.path} does not exist');}}" + ) return source @@ -218,55 +190,51 @@ def operations_to_script(operations: List) -> Dict: """ source = "" for operation in operations: - op_path = split_json_path(operation.path) + op_path = ElasticPath(path=operation.path) if hasattr(operation, "from"): - from_path = split_json_path(getattr(operation, "from")) - source = script_checks(source, operation.op, op_path) - if from_path["index"]: - from_key = from_path["nest"] + from_path["partition"] + from_path["key"] + from_path = ElasticPath(path=(getattr(operation, "from"))) + source = add_script_checks(source, operation.op, from_path) + if from_path.index: source += ( - f"if ((ctx._source.{from_key} instanceof ArrayList && ctx._source.{from_key}.size() < {from_path['index']})" - f"|| (!ctx._source.{from_key + from_path['partition']}containsKey('{from_path['index']}'))" - f"{{Debug.explain('{from_path['path']} does not exist');}}" + f"if ((ctx._source.{from_path.location} instanceof ArrayList" + f" && ctx._source.{from_path.location}.size() < {from_path.index})" + f" || (!ctx._source.{from_path.location}.containsKey('{from_path.index}'))" + f"{{Debug.explain('{from_path.path} does not exist');}}" ) - source = script_checks(source, operation.op, op_path) + source = add_script_checks(source, operation.op, op_path) if operation.op in ["copy", "move"]: - if op_path["index"]: + if op_path.index: source += ( - f"if (ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key']} instanceof ArrayList)" - f"{{ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key'] + op_path['partition']}add({op_path['index']}, {from_path['path']})}}" - f"else{{ctx._source.{op_path['path']} = {from_path['path']}}}" + f"if (ctx._source.{op_path.location} instanceof ArrayList)" + f"{{ctx._source.{op_path.location}.add({op_path.index}, {from_path.path})}}" + f"else{{ctx._source.{op_path.path} = {from_path.path}}}" ) else: - source += ( - f"ctx._source.{op_path['path']} = ctx._source.{from_path['path']};" - ) + source += f"ctx._source.{op_path.path} = ctx._source.{from_path.path};" if operation.op in ["remove", "move"]: remove_path = from_path if operation.op == "move" else op_path - if remove_path["index"]: - source += f"ctx._source.{remove_path['nest'] + remove_path['partition'] + remove_path['key'] + remove_path['partition']}remove('{remove_path['index']}');" + if remove_path.index: + source += f"ctx._source.{remove_path.location}.remove('{remove_path.index}');" else: - source += f"ctx._source.{remove_path['nest'] + remove_path['partition']}remove('{remove_path['key']}');" + source += f"ctx._source.remove('{remove_path.location}');" if operation.op in ["add", "replace"]: if op_path["index"]: source += ( - f"if (ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key']} instanceof ArrayList)" - f"{{ctx._source.{op_path['nest'] + op_path['partition'] + op_path['key'] + op_path['partition']}add({op_path['index']}, {json.dumps(operation.value)})}}" - f"else{{ctx._source.{op_path['path']} = {json.dumps(operation.value)}}}" + f"if (ctx._source.{op_path.location} instanceof ArrayList)" + f"{{ctx._source.{op_path.location}.add({op_path.index}, {operation.json_value})}}" + f"else{{ctx._source.{op_path.path} = {operation.json_value}}}" ) else: - source += ( - f"ctx._source.{op_path['path']} = {json.dumps(operation.value)};" - ) + source += f"ctx._source.{op_path.path} = {operation.json_value};" return { "source": source, diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index d973efef..445a4626 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -166,7 +166,9 @@ def index_by_collection_id(collection_id: str) -> str: Returns: str: The index name derived from the collection id. """ - return f"{ITEMS_INDEX_PREFIX}{''.join(c for c in collection_id.lower() if c not in ES_INDEX_NAME_UNSUPPORTED_CHARS)}" + return ( + f"{ITEMS_INDEX_PREFIX}{''.join(c for c in collection_id.lower() if c not in ES_INDEX_NAME_UNSUPPORTED_CHARS)}" + ) def indices(collection_ids: Optional[List[str]]) -> str: @@ -322,9 +324,7 @@ class DatabaseLogic: sync_client = SyncElasticsearchSettings().create_client item_serializer: Type[ItemSerializer] = attr.ib(default=ItemSerializer) - collection_serializer: Type[CollectionSerializer] = attr.ib( - default=CollectionSerializer - ) + collection_serializer: Type[CollectionSerializer] = attr.ib(default=CollectionSerializer) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -392,9 +392,7 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: id=mk_item_id(item_id, collection_id), ) except exceptions.NotFoundError: - raise NotFoundError( - f"Item {item_id} does not exist in Collection {collection_id}" - ) + raise NotFoundError(f"Item {item_id} does not exist in Collection {collection_id}") return item["_source"] @staticmethod @@ -424,16 +422,10 @@ def apply_datetime_filter(search: Search, datetime_search): Search: The filtered search object. """ if "eq" in datetime_search: - search = search.filter( - "term", **{"properties__datetime": datetime_search["eq"]} - ) + search = search.filter("term", **{"properties__datetime": datetime_search["eq"]}) else: - search = search.filter( - "range", properties__datetime={"lte": datetime_search["lte"]} - ) - search = search.filter( - "range", properties__datetime={"gte": datetime_search["gte"]} - ) + search = search.filter("range", properties__datetime={"lte": datetime_search["lte"]}) + search = search.filter("range", properties__datetime={"gte": datetime_search["gte"]}) return search @staticmethod @@ -629,15 +621,9 @@ async def execute_search( next_token = None if len(hits) > limit and limit < max_result_window: if hits and (sort_array := hits[limit - 1].get("sort")): - next_token = urlsafe_b64encode( - ",".join([str(x) for x in sort_array]).encode() - ).decode() - - matched = ( - es_response["hits"]["total"]["value"] - if es_response["hits"]["total"]["relation"] == "eq" - else None - ) + next_token = urlsafe_b64encode(",".join([str(x) for x in sort_array]).encode()).decode() + + matched = es_response["hits"]["total"]["value"] if es_response["hits"]["total"]["relation"] == "eq" else None if count_task.done(): try: matched = count_task.result().get("count") @@ -664,9 +650,7 @@ async def aggregate( agg_2_es = { "total_count": {"value_count": {"field": "id"}}, "collection_frequency": {"terms": {"field": "collection", "size": 100}}, - "platform_frequency": { - "terms": {"field": "properties.platform", "size": 100} - }, + "platform_frequency": {"terms": {"field": "properties.platform", "size": 100}}, "cloud_cover_frequency": { "range": { "field": "properties.eo:cloud_cover", @@ -693,15 +677,9 @@ async def aggregate( "size": 10000, } }, - "sun_elevation_frequency": { - "histogram": {"field": "properties.view:sun_elevation", "interval": 5} - }, - "sun_azimuth_frequency": { - "histogram": {"field": "properties.view:sun_azimuth", "interval": 5} - }, - "off_nadir_frequency": { - "histogram": {"field": "properties.view:off_nadir", "interval": 5} - }, + "sun_elevation_frequency": {"histogram": {"field": "properties.view:sun_elevation", "interval": 5}}, + "sun_azimuth_frequency": {"histogram": {"field": "properties.view:sun_azimuth", "interval": 5}}, + "off_nadir_frequency": {"histogram": {"field": "properties.view:off_nadir", "interval": 5}}, } search_body: Dict[str, Any] = {} @@ -713,9 +691,7 @@ async def aggregate( # include all aggregations specified # this will ignore aggregations with the wrong names - search_body["aggregations"] = { - k: v for k, v in agg_2_es.items() if k in aggregations - } + search_body["aggregations"] = {k: v for k, v in agg_2_es.items() if k in aggregations} if "centroid_geohash_grid_frequency" in aggregations: search_body["aggregations"]["centroid_geohash_grid_frequency"] = { @@ -780,9 +756,7 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item( - self, item: Item, base_url: str, exist_ok: bool = False - ) -> Item: + async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: """ Preps an item for insertion into the database. @@ -804,15 +778,11 @@ async def prep_create_item( index=index_by_collection_id(item["collection"]), id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError( - f"Item {item['id']} in collection {item['collection']} already exists" - ) + raise ConflictError(f"Item {item['id']} in collection {item['collection']} already exists") return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item( - self, item: Item, base_url: str, exist_ok: bool = False - ) -> Item: + def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: """ Prepare an item for insertion into the database. @@ -841,9 +811,7 @@ def sync_prep_create_item( index=index_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), ): - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) + raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") return self.item_serializer.stac_to_db(item, base_url) @@ -871,9 +839,7 @@ async def create_item(self, item: Item, refresh: bool = False): ) if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) + raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") async def merge_patch_item( self, @@ -946,21 +912,23 @@ async def json_patch_item( script = operations_to_script(script_operations) - if not new_collection_id and not new_item_id: - try: - await self.client.update( - index=index_by_collection_id(collection_id), - id=mk_item_id(item_id, collection_id), - script=script, - refresh=refresh, - ) - except exceptions.BadRequestError as e: - raise KeyError(e.info["error"]["caused_by"]["to_string"]) + try: + await self.client.update( + index=index_by_collection_id(collection_id), + id=mk_item_id(item_id, collection_id), + script=script, + refresh=True, + ) + + except exceptions.BadRequestError as exc: + raise KeyError(exc.info["error"]["caused_by"]["to_string"]) from exc + + item = await self.get_one_item(collection_id, item_id) if new_collection_id: await self.client.reindex( body={ - "dest": {"index": f"{ITEMS_INDEX_PREFIX}{operation['value']}"}, + "dest": {"index": f"{ITEMS_INDEX_PREFIX}{new_collection_id}"}, "source": { "index": f"{ITEMS_INDEX_PREFIX}{collection_id}", "query": {"term": {"id": {"value": item_id}}}, @@ -968,24 +936,22 @@ async def json_patch_item( "script": { "lang": "painless", "source": ( - f"""ctx._id = ctx._id.replace('{collection_id}', '{operation["value"]}');""" - f"""ctx._source.collection = '{operation["value"]}';""" - + script + f"""ctx._id = ctx._id.replace('{collection_id}', '{new_collection_id}');""" + f"""ctx._source.collection = '{new_collection_id}';""" ), }, }, wait_for_completion=True, - refresh=False, + refresh=True, ) - - item = await self.get_one_item(collection_id, item_id) + item["collection"] = new_collection_id if new_item_id: item["id"] = new_item_id item = await self.prep_create_item(item=item, base_url=base_url) await self.create_item(item=item, refresh=False) - if new_item_id or new_collection_id: + if new_collection_id or new_item_id: await self.delete_item( item_id=item_id, @@ -995,9 +961,7 @@ async def json_patch_item( return item - async def delete_item( - self, item_id: str, collection_id: str, refresh: bool = False - ): + async def delete_item(self, item_id: str, collection_id: str, refresh: bool = False): """Delete a single item from the database. Args: @@ -1015,9 +979,7 @@ async def delete_item( refresh=refresh, ) except exceptions.NotFoundError: - raise NotFoundError( - f"Item {item_id} in collection {collection_id} not found" - ) + raise NotFoundError(f"Item {item_id} in collection {collection_id} not found") async def create_collection(self, collection: Collection, refresh: bool = False): """Create a single collection in the database. @@ -1064,17 +1026,13 @@ async def find_collection(self, collection_id: str) -> Collection: collection as a `Collection` object. If the collection is not found, a `NotFoundError` is raised. """ try: - collection = await self.client.get( - index=COLLECTIONS_INDEX, id=collection_id - ) + collection = await self.client.get(index=COLLECTIONS_INDEX, id=collection_id) except exceptions.NotFoundError: raise NotFoundError(f"Collection {collection_id} not found") return collection["_source"] - async def update_collection( - self, collection_id: str, collection: Collection, refresh: bool = False - ): + async def update_collection(self, collection_id: str, collection: Collection, refresh: bool = False): """Update a collection from the database. Args: @@ -1170,32 +1128,34 @@ async def json_patch_collection( for operation in operations: if ( - operation.get("op") in ["add", "replace"] - and operation.get("path") == "collection" - and collection_id != operation["value"] + operation.op in ["add", "replace"] + and operation.path == "collection" + and collection_id != operation.value ): - new_collection_id = operation["value"] + new_collection_id = operation.value else: script_operations.append(operation) script = operations_to_script(script_operations) - if not new_collection_id: - await self.client.update( - index=COLLECTIONS_INDEX, - id=collection_id, - script=script, - refresh=refresh, - ) + await self.client.update( + index=COLLECTIONS_INDEX, + id=collection_id, + script=script, + refresh=True, + ) collection = await self.find_collection(collection_id) if new_collection_id: collection["id"] = new_collection_id collection["links"] = resolve_links([], base_url) + await self.update_collection( - collection_id=collection_id, collection=collection, refresh=False + collection_id=collection_id, + collection=collection, + refresh=False, ) return collection @@ -1217,14 +1177,10 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): function also calls `delete_item_index` to delete the index for the items in the collection. """ await self.find_collection(collection_id=collection_id) - await self.client.delete( - index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh - ) + await self.client.delete(index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh) await delete_item_index(collection_id) - async def bulk_async( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: + async def bulk_async(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: """Perform a bulk insert of items into the database asynchronously. Args: @@ -1246,9 +1202,7 @@ async def bulk_async( raise_on_error=False, ) - def bulk_sync( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: + def bulk_sync(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: """Perform a bulk insert of items into the database synchronously. Args: From d94d8fe5dc73669a7d1999e1c2a8f00c8bd925ae Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 10:30:37 +0000 Subject: [PATCH 08/38] Switch to use pydantic model for operation path. --- .../core/stac_fastapi/core/utilities.py | 9 +- .../elasticsearch/database_logic.py | 6 +- .../tests/clients/test_elasticsearch.py | 187 +++++++++++++++--- 3 files changed, 168 insertions(+), 34 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 4c4ea920..7ed31452 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -191,6 +191,7 @@ def operations_to_script(operations: List) -> Dict: source = "" for operation in operations: op_path = ElasticPath(path=operation.path) + source = add_script_checks(source, operation.op, op_path) if hasattr(operation, "from"): from_path = ElasticPath(path=(getattr(operation, "from"))) @@ -203,8 +204,6 @@ def operations_to_script(operations: List) -> Dict: f"{{Debug.explain('{from_path.path} does not exist');}}" ) - source = add_script_checks(source, operation.op, op_path) - if operation.op in ["copy", "move"]: if op_path.index: source += ( @@ -236,6 +235,12 @@ def operations_to_script(operations: List) -> Dict: else: source += f"ctx._source.{op_path.path} = {operation.json_value};" + if operation.op == "test": + source += ( + f"if (ctx._source.{op_path.location} != {operation.json_value})" + f"{{Debug.explain('Test failed for: {op_path.path} | {operation.json_value} != ctx._source.{op_path.location}');}}" + ) + return { "source": source, "lang": "painless", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 445a4626..ae94c1a7 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -896,10 +896,8 @@ async def json_patch_item( script_operations = [] for operation in operations: - if operation.path in [ - "collection", - "id", - ] and operation.op in ["add", "replace"]: + if operation.path in ["collection", "id"] and operation.op in ["add", "replace"]: + if operation.path == "collection" and collection_id != operation.value: await self.check_collection_exists(collection_id=operation.value) new_collection_id = operation.value diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index a0867ad3..6f744289 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -43,9 +43,7 @@ async def test_update_collection( collection_data = load_test_data("test_collection.json") item_data = load_test_data("test_item.json") - await txn_client.create_collection( - api.Collection(**collection_data), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -54,9 +52,7 @@ async def test_update_collection( ) collection_data["keywords"].append("new keyword") - await txn_client.update_collection( - collection_data["id"], api.Collection(**collection_data), request=MockRequest - ) + await txn_client.update_collection(collection_data["id"], api.Collection(**collection_data), request=MockRequest) coll = await core_client.get_collection(collection_data["id"], request=MockRequest) assert "new keyword" in coll["keywords"] @@ -83,9 +79,7 @@ async def test_update_collection_id( item_data = load_test_data("test_item.json") new_collection_id = "new-test-collection" - await txn_client.create_collection( - api.Collection(**collection_data), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -197,14 +191,10 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): @pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): - resp = await core_client.get_item( - ctx.item["id"], ctx.item["collection"], request=MockRequest - ) - assert Item(**ctx.item).model_dump( - exclude={"links": ..., "properties": {"created", "updated"}} - ) == Item(**resp).model_dump( - exclude={"links": ..., "properties": {"created", "updated"}} - ) + resp = await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) + assert Item(**ctx.item).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) == Item( + **resp + ).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) @pytest.mark.asyncio @@ -231,10 +221,157 @@ async def test_update_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + assert updated_item["properties"]["foo"] == "bar" + + +@pytest.mark.asyncio +async def test_merge_patch_item(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + await txn_client.merge_patch_item( + collection_id=collection_id, + item_id=item_id, + item={"properties": {"foo": "bar", "gsd": None}}, + request=MockRequest, ) + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == "bar" + assert "gsd" not in updated_item["properties"] + + +@pytest.mark.asyncio +async def test_json_patch_item(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "add", "path": "properties.bar", "value": "foo"}, + {"op": "remove", "path": "properties.instrument"}, + {"op": "replace", "path": "properties.width", "value": 100}, + {"op": "test", "path": "properties.platform", "value": "landsat-8"}, + {"op": "move", "path": "properties.hello", "from": "properties.height"}, + {"op": "copy", "path": "properties.world", "from": "properties.proj:epsg"}, + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + # add foo + assert updated_item["properties"]["bar"] == "foo" + # remove gsd + assert "instrument" not in updated_item["properties"] + # replace width + assert updated_item["properties"]["width"] == 100 + # move height + assert updated_item["properties"]["hello"] == item["properties"]["height"] + assert "height" not in updated_item["properties"] + # copy proj:epsg + assert updated_item["properties"]["world"] == item["properties"]["proj:epsg"] + assert updated_item["properties"]["proj:epsg"] == item["properties"]["proj:epsg"] + + +@pytest.mark.asyncio +async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "test", "path": "properties.platform", "value": "landsat-9"}, + ] + + with pytest.raises(ConflictError): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "replace", "path": "properties.platforms", "value": "landsat-9"}, + ] + + with pytest.raises(ConflictError): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "remove", "path": "properties.platforms"}, + ] + + with pytest.raises(ConflictError): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "move", "path": "properties.platformed", "from": "properties.platforms"}, + ] + + with pytest.raises(ConflictError): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_item_copy_property_does_not_exists(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "copy", "path": "properties.platformed", "from": "properties.platforms"}, + ] + + with pytest.raises(ConflictError): + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) @pytest.mark.asyncio @@ -259,9 +396,7 @@ async def test_update_geometry(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["geometry"]["coordinates"] == new_coordinates @@ -270,9 +405,7 @@ async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) with pytest.raises(NotFoundError): - await core_client.get_item( - ctx.item["id"], ctx.item["collection"], request=MockRequest - ) + await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) @pytest.mark.asyncio @@ -321,9 +454,7 @@ async def test_feature_collection_insert( async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] - await txn_client.create_collection( - api.Collection(**ctx.collection), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**ctx.collection), request=MockRequest) landing_page = await core_client.landing_page(request=MockRequest(app=app)) for link in landing_page["links"]: From 093c5935c6026f06e8e6999754df2ba1d8729abc Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 11:52:02 +0000 Subject: [PATCH 09/38] Adding computed_field output type. --- stac_fastapi/core/stac_fastapi/core/models/patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index 74135992..8d87dd35 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -31,5 +31,5 @@ def __init__(self, *, path: str): @computed_field @property - def location(self): + def location(self) -> str: return self.nest + self.partition + self.key From d4bd07a2588cf1e4ea590a42cfc6db05561cdc81 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 12:03:02 +0000 Subject: [PATCH 10/38] pre-commit. --- .../core/stac_fastapi/core/models/patch.py | 12 ++- .../core/stac_fastapi/core/utilities.py | 27 ++++-- .../elasticsearch/database_logic.py | 95 ++++++++++++++----- .../tests/clients/test_elasticsearch.py | 64 +++++++++---- 4 files changed, 151 insertions(+), 47 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index 8d87dd35..91a76b90 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -20,6 +20,11 @@ class ElasticPath(BaseModel): index: Optional[int] = None def __init__(self, *, path: str): + """Convert JSON path to Elasticsearch script path. + + Args: + path (str): initial JSON path + """ self.path = path.lstrip("/").replace("/", ".") self.nest, self.partition, self.key = path.rpartition(".") @@ -29,7 +34,12 @@ def __init__(self, *, path: str): self.path = f"{self.nest}[{self.index}]" self.nest, self.partition, self.key = self.nest.rpartition(".") - @computed_field + @computed_field # type: ignore[misc] @property def location(self) -> str: + """Compute location of path. + + Returns: + str: path location + """ return self.nest + self.partition + self.key diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 7ed31452..d744d9ec 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -45,7 +45,9 @@ def filter_fields( # noqa: C901 return item # Build a shallow copy of included fields on an item, or a sub-tree of an item - def include_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> Dict[str, Any]: + def include_fields( + source: Dict[str, Any], fields: Optional[Set[str]] + ) -> Dict[str, Any]: if not fields: return source @@ -58,7 +60,9 @@ def include_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> Dict[s # The root of this key path on the item is a dict, and the # key path indicates a sub-key to be included. Walk the dict # from the root key and get the full nested value to include. - value = include_fields(source[key_root], fields={".".join(key_path_parts[1:])}) + value = include_fields( + source[key_root], fields={".".join(key_path_parts[1:])} + ) if isinstance(clean_item.get(key_root), dict): # A previously specified key and sub-keys may have been included @@ -89,7 +93,9 @@ def exclude_fields(source: Dict[str, Any], fields: Optional[Set[str]]) -> None: if key_root in source: if isinstance(source[key_root], dict) and len(key_path_part) > 1: # Walk the nested path of this key to remove the leaf-key - exclude_fields(source[key_root], fields={".".join(key_path_part[1:])}) + exclude_fields( + source[key_root], fields={".".join(key_path_part[1:])} + ) # If, after removing the leaf-key, the root is now an empty # dict, remove it entirely if not source[key_root]: @@ -121,7 +127,11 @@ def dict_deep_update(merge_to: Dict[str, Any], merge_from: Dict[str, Any]) -> No merge_from values take precedence over existing values in merge_to. """ for k, v in merge_from.items(): - if k in merge_to and isinstance(merge_to[k], dict) and isinstance(merge_from[k], dict): + if ( + k in merge_to + and isinstance(merge_to[k], dict) + and isinstance(merge_from[k], dict) + ): dict_deep_update(merge_to[k], merge_from[k]) else: merge_to[k] = v @@ -168,7 +178,10 @@ def add_script_checks(source: str, op: str, path: ElasticPath) -> str: Dict: update source of Elasticsearch script """ if path.nest: - source += f"if (!ctx._source.containsKey('{path.nest}'))" f"{{Debug.explain('{path.nest} does not exist');}}" + source += ( + f"if (!ctx._source.containsKey('{path.nest}'))" + f"{{Debug.explain('{path.nest} does not exist');}}" + ) if path.index or op != "add": source += ( @@ -219,7 +232,9 @@ def operations_to_script(operations: List) -> Dict: remove_path = from_path if operation.op == "move" else op_path if remove_path.index: - source += f"ctx._source.{remove_path.location}.remove('{remove_path.index}');" + source += ( + f"ctx._source.{remove_path.location}.remove('{remove_path.index}');" + ) else: source += f"ctx._source.remove('{remove_path.location}');" diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index d9d3a498..9b743dcc 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -336,7 +336,9 @@ class DatabaseLogic: sync_client = SyncElasticsearchSettings().create_client item_serializer: Type[ItemSerializer] = attr.ib(default=ItemSerializer) - collection_serializer: Type[CollectionSerializer] = attr.ib(default=CollectionSerializer) + collection_serializer: Type[CollectionSerializer] = attr.ib( + default=CollectionSerializer + ) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -370,9 +372,15 @@ class DatabaseLogic: "size": 10000, } }, - "sun_elevation_frequency": {"histogram": {"field": "properties.view:sun_elevation", "interval": 5}}, - "sun_azimuth_frequency": {"histogram": {"field": "properties.view:sun_azimuth", "interval": 5}}, - "off_nadir_frequency": {"histogram": {"field": "properties.view:off_nadir", "interval": 5}}, + "sun_elevation_frequency": { + "histogram": {"field": "properties.view:sun_elevation", "interval": 5} + }, + "sun_azimuth_frequency": { + "histogram": {"field": "properties.view:sun_azimuth", "interval": 5} + }, + "off_nadir_frequency": { + "histogram": {"field": "properties.view:off_nadir", "interval": 5} + }, "centroid_geohash_grid_frequency": { "geohash_grid": { "field": "properties.proj:centroid", @@ -469,7 +477,9 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: id=mk_item_id(item_id, collection_id), ) except exceptions.NotFoundError: - raise NotFoundError(f"Item {item_id} does not exist in Collection {collection_id}") + raise NotFoundError( + f"Item {item_id} does not exist in Collection {collection_id}" + ) return item["_source"] @staticmethod @@ -499,10 +509,16 @@ def apply_datetime_filter(search: Search, datetime_search): Search: The filtered search object. """ if "eq" in datetime_search: - search = search.filter("term", **{"properties__datetime": datetime_search["eq"]}) + search = search.filter( + "term", **{"properties__datetime": datetime_search["eq"]} + ) else: - search = search.filter("range", properties__datetime={"lte": datetime_search["lte"]}) - search = search.filter("range", properties__datetime={"gte": datetime_search["gte"]}) + search = search.filter( + "range", properties__datetime={"lte": datetime_search["lte"]} + ) + search = search.filter( + "range", properties__datetime={"gte": datetime_search["gte"]} + ) return search @staticmethod @@ -596,7 +612,9 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] """Database logic to perform query for search endpoint.""" if free_text_queries is not None: free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) - search = search.query("query_string", query=f'properties.\\*:"{free_text_query_string}"') + search = search.query( + "query_string", query=f'properties.\\*:"{free_text_query_string}"' + ) return search @@ -709,7 +727,11 @@ async def execute_search( if hits and (sort_array := hits[limit - 1].get("sort")): next_token = urlsafe_b64encode(json.dumps(sort_array).encode()).decode() - matched = es_response["hits"]["total"]["value"] if es_response["hits"]["total"]["relation"] == "eq" else None + matched = ( + es_response["hits"]["total"]["value"] + if es_response["hits"]["total"]["relation"] == "eq" + else None + ) if count_task.done(): try: matched = count_task.result().get("count") @@ -789,7 +811,9 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: + async def prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: """ Preps an item for insertion into the database. @@ -811,11 +835,15 @@ async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = Fal index=index_alias_by_collection_id(item["collection"]), id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError(f"Item {item['id']} in collection {item['collection']} already exists") + raise ConflictError( + f"Item {item['id']} in collection {item['collection']} already exists" + ) return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: + def sync_prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: """ Prepare an item for insertion into the database. @@ -844,7 +872,9 @@ def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = Fals index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), ): - raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") + raise ConflictError( + f"Item {item_id} in collection {collection_id} already exists" + ) return self.item_serializer.stac_to_db(item, base_url) @@ -872,7 +902,9 @@ async def create_item(self, item: Item, refresh: bool = False): ) if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") + raise ConflictError( + f"Item {item_id} in collection {collection_id} already exists" + ) async def merge_patch_item( self, @@ -929,7 +961,10 @@ async def json_patch_item( script_operations = [] for operation in operations: - if operation.path in ["collection", "id"] and operation.op in ["add", "replace"]: + if operation.path in ["collection", "id"] and operation.op in [ + "add", + "replace", + ]: if operation.path == "collection" and collection_id != operation.value: await self.check_collection_exists(collection_id=operation.value) @@ -992,7 +1027,9 @@ async def json_patch_item( return item - async def delete_item(self, item_id: str, collection_id: str, refresh: bool = False): + async def delete_item( + self, item_id: str, collection_id: str, refresh: bool = False + ): """Delete a single item from the database. Args: @@ -1010,7 +1047,9 @@ async def delete_item(self, item_id: str, collection_id: str, refresh: bool = Fa refresh=refresh, ) except exceptions.NotFoundError: - raise NotFoundError(f"Item {item_id} in collection {collection_id} not found") + raise NotFoundError( + f"Item {item_id} in collection {collection_id} not found" + ) async def create_collection(self, collection: Collection, refresh: bool = False): """Create a single collection in the database. @@ -1057,13 +1096,17 @@ async def find_collection(self, collection_id: str) -> Collection: collection as a `Collection` object. If the collection is not found, a `NotFoundError` is raised. """ try: - collection = await self.client.get(index=COLLECTIONS_INDEX, id=collection_id) + collection = await self.client.get( + index=COLLECTIONS_INDEX, id=collection_id + ) except exceptions.NotFoundError: raise NotFoundError(f"Collection {collection_id} not found") return collection["_source"] - async def update_collection(self, collection_id: str, collection: Collection, refresh: bool = False): + async def update_collection( + self, collection_id: str, collection: Collection, refresh: bool = False + ): """Update a collection from the database. Args: @@ -1208,10 +1251,14 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): function also calls `delete_item_index` to delete the index for the items in the collection. """ await self.find_collection(collection_id=collection_id) - await self.client.delete(index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh) + await self.client.delete( + index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh + ) await delete_item_index(collection_id) - async def bulk_async(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: + async def bulk_async( + self, collection_id: str, processed_items: List[Item], refresh: bool = False + ) -> None: """Perform a bulk insert of items into the database asynchronously. Args: @@ -1233,7 +1280,9 @@ async def bulk_async(self, collection_id: str, processed_items: List[Item], refr raise_on_error=False, ) - def bulk_sync(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: + def bulk_sync( + self, collection_id: str, processed_items: List[Item], refresh: bool = False + ) -> None: """Perform a bulk insert of items into the database synchronously. Args: diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 6f744289..6c216605 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -43,7 +43,9 @@ async def test_update_collection( collection_data = load_test_data("test_collection.json") item_data = load_test_data("test_item.json") - await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) + await txn_client.create_collection( + api.Collection(**collection_data), request=MockRequest + ) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -52,7 +54,9 @@ async def test_update_collection( ) collection_data["keywords"].append("new keyword") - await txn_client.update_collection(collection_data["id"], api.Collection(**collection_data), request=MockRequest) + await txn_client.update_collection( + collection_data["id"], api.Collection(**collection_data), request=MockRequest + ) coll = await core_client.get_collection(collection_data["id"], request=MockRequest) assert "new keyword" in coll["keywords"] @@ -79,7 +83,9 @@ async def test_update_collection_id( item_data = load_test_data("test_item.json") new_collection_id = "new-test-collection" - await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) + await txn_client.create_collection( + api.Collection(**collection_data), request=MockRequest + ) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -191,10 +197,14 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): @pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): - resp = await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) - assert Item(**ctx.item).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) == Item( - **resp - ).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) + resp = await core_client.get_item( + ctx.item["id"], ctx.item["collection"], request=MockRequest + ) + assert Item(**ctx.item).model_dump( + exclude={"links": ..., "properties": {"created", "updated"}} + ) == Item(**resp).model_dump( + exclude={"links": ..., "properties": {"created", "updated"}} + ) @pytest.mark.asyncio @@ -221,7 +231,9 @@ async def test_update_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == "bar" @@ -237,7 +249,9 @@ async def test_merge_patch_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == "bar" assert "gsd" not in updated_item["properties"] @@ -263,7 +277,9 @@ async def test_json_patch_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) # add foo assert updated_item["properties"]["bar"] == "foo" @@ -299,7 +315,9 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): @pytest.mark.asyncio -async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_replace_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -318,7 +336,9 @@ async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client @pytest.mark.asyncio -async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_remove_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -337,7 +357,9 @@ async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, @pytest.mark.asyncio -async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_move_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -356,7 +378,9 @@ async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, t @pytest.mark.asyncio -async def test_json_patch_item_copy_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_copy_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -396,7 +420,9 @@ async def test_update_geometry(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["geometry"]["coordinates"] == new_coordinates @@ -405,7 +431,9 @@ async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) with pytest.raises(NotFoundError): - await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) + await core_client.get_item( + ctx.item["id"], ctx.item["collection"], request=MockRequest + ) @pytest.mark.asyncio @@ -454,7 +482,9 @@ async def test_feature_collection_insert( async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] - await txn_client.create_collection(api.Collection(**ctx.collection), request=MockRequest) + await txn_client.create_collection( + api.Collection(**ctx.collection), request=MockRequest + ) landing_page = await core_client.landing_page(request=MockRequest(app=app)) for link in landing_page["links"]: From b6b07212121dd23d140868e18dfbabda7925cb3b Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 12:26:35 +0000 Subject: [PATCH 11/38] Add changes to opensearch. --- .../stac_fastapi/opensearch/database_logic.py | 144 ++++++------------ stac_fastapi/tests/api/test_api.py | 2 + 2 files changed, 50 insertions(+), 96 deletions(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 86d7d8ce..88cee988 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -273,9 +273,7 @@ async def create_item_index(collection_id: str): } try: - await client.indices.create( - index=f"{index_by_collection_id(collection_id)}-000001", body=search_body - ) + await client.indices.create(index=f"{index_by_collection_id(collection_id)}-000001", body=search_body) except TransportError as e: if e.status_code == 400: pass # Ignore 400 status codes @@ -356,12 +354,8 @@ class DatabaseLogic: client = AsyncSearchSettings().create_client sync_client = SyncSearchSettings().create_client - item_serializer: Type[serializers.ItemSerializer] = attr.ib( - default=serializers.ItemSerializer - ) - collection_serializer: Type[serializers.CollectionSerializer] = attr.ib( - default=serializers.CollectionSerializer - ) + item_serializer: Type[serializers.ItemSerializer] = attr.ib(default=serializers.ItemSerializer) + collection_serializer: Type[serializers.CollectionSerializer] = attr.ib(default=serializers.CollectionSerializer) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -395,15 +389,9 @@ class DatabaseLogic: "size": 10000, } }, - "sun_elevation_frequency": { - "histogram": {"field": "properties.view:sun_elevation", "interval": 5} - }, - "sun_azimuth_frequency": { - "histogram": {"field": "properties.view:sun_azimuth", "interval": 5} - }, - "off_nadir_frequency": { - "histogram": {"field": "properties.view:off_nadir", "interval": 5} - }, + "sun_elevation_frequency": {"histogram": {"field": "properties.view:sun_elevation", "interval": 5}}, + "sun_azimuth_frequency": {"histogram": {"field": "properties.view:sun_azimuth", "interval": 5}}, + "off_nadir_frequency": {"histogram": {"field": "properties.view:off_nadir", "interval": 5}}, "centroid_geohash_grid_frequency": { "geohash_grid": { "field": "properties.proj:centroid", @@ -506,9 +494,7 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: id=mk_item_id(item_id, collection_id), ) except exceptions.NotFoundError: - raise NotFoundError( - f"Item {item_id} does not exist in Collection {collection_id}" - ) + raise NotFoundError(f"Item {item_id} does not exist in Collection {collection_id}") return item["_source"] @staticmethod @@ -531,9 +517,7 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] """Database logic to perform query for search endpoint.""" if free_text_queries is not None: free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) - search = search.query( - "query_string", query=f'properties.\\*:"{free_text_query_string}"' - ) + search = search.query("query_string", query=f'properties.\\*:"{free_text_query_string}"') return search @@ -549,16 +533,10 @@ def apply_datetime_filter(search: Search, datetime_search): Search: The filtered search object. """ if "eq" in datetime_search: - search = search.filter( - "term", **{"properties__datetime": datetime_search["eq"]} - ) + search = search.filter("term", **{"properties__datetime": datetime_search["eq"]}) else: - search = search.filter( - "range", properties__datetime={"lte": datetime_search["lte"]} - ) - search = search.filter( - "range", properties__datetime={"gte": datetime_search["gte"]} - ) + search = search.filter("range", properties__datetime={"lte": datetime_search["lte"]}) + search = search.filter("range", properties__datetime={"gte": datetime_search["gte"]}) return search @staticmethod @@ -761,11 +739,7 @@ async def execute_search( if hits and (sort_array := hits[limit - 1].get("sort")): next_token = urlsafe_b64encode(json.dumps(sort_array).encode()).decode() - matched = ( - es_response["hits"]["total"]["value"] - if es_response["hits"]["total"]["relation"] == "eq" - else None - ) + matched = es_response["hits"]["total"]["value"] if es_response["hits"]["total"]["relation"] == "eq" else None if count_task.done(): try: matched = count_task.result().get("count") @@ -843,9 +817,7 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item( - self, item: Item, base_url: str, exist_ok: bool = False - ) -> Item: + async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: """ Preps an item for insertion into the database. @@ -867,15 +839,11 @@ async def prep_create_item( index=index_alias_by_collection_id(item["collection"]), id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError( - f"Item {item['id']} in collection {item['collection']} already exists" - ) + raise ConflictError(f"Item {item['id']} in collection {item['collection']} already exists") return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item( - self, item: Item, base_url: str, exist_ok: bool = False - ) -> Item: + def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: """ Prepare an item for insertion into the database. @@ -904,9 +872,7 @@ def sync_prep_create_item( index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), ): - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) + raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") return self.item_serializer.stac_to_db(item, base_url) @@ -934,9 +900,7 @@ async def create_item(self, item: Item, refresh: bool = False): ) if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) + raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") async def merge_patch_item( self, @@ -993,34 +957,40 @@ async def json_patch_item( script_operations = [] for operation in operations: - if operation["op"] in ["add", "replace"]: - if ( - operation["path"] == "collection" - and collection_id != operation["value"] - ): - await self.check_collection_exists(collection_id=operation["value"]) - new_collection_id = operation["value"] + if operation.path in ["collection", "id"] and operation.op in [ + "add", + "replace", + ]: + + if operation.path == "collection" and collection_id != operation.value: + await self.check_collection_exists(collection_id=operation.value) + new_collection_id = operation.value - if operation["path"] == "id" and item_id != operation["value"]: - new_item_id = operation["value"] + if operation.path == "id" and item_id != operation.value: + new_item_id = operation.value else: script_operations.append(operation) script = operations_to_script(script_operations) - if not new_collection_id and not new_item_id: + try: await self.client.update( index=index_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), script=script, - refresh=refresh, + refresh=True, ) + except exceptions.BadRequestError as exc: + raise KeyError(exc.info["error"]["caused_by"]["to_string"]) from exc + + item = await self.get_one_item(collection_id, item_id) + if new_collection_id: await self.client.reindex( body={ - "dest": {"index": f"{ITEMS_INDEX_PREFIX}{operation['value']}"}, + "dest": {"index": f"{ITEMS_INDEX_PREFIX}{new_collection_id}"}, "source": { "index": f"{ITEMS_INDEX_PREFIX}{collection_id}", "query": {"term": {"id": {"value": item_id}}}, @@ -1028,24 +998,22 @@ async def json_patch_item( "script": { "lang": "painless", "source": ( - f"""ctx._id = ctx._id.replace('{collection_id}', '{operation["value"]}');""" - f"""ctx._source.collection = '{operation["value"]}';""" - + script + f"""ctx._id = ctx._id.replace('{collection_id}', '{new_collection_id}');""" + f"""ctx._source.collection = '{new_collection_id}';""" ), }, }, wait_for_completion=True, - refresh=False, + refresh=True, ) - - item = await self.get_one_item(collection_id, item_id) + item["collection"] = new_collection_id if new_item_id: item["id"] = new_item_id item = await self.prep_create_item(item=item, base_url=base_url) await self.create_item(item=item, refresh=False) - if new_item_id or new_collection_id: + if new_collection_id or new_item_id: await self.delete_item( item_id=item_id, @@ -1055,9 +1023,7 @@ async def json_patch_item( return item - async def delete_item( - self, item_id: str, collection_id: str, refresh: bool = False - ): + async def delete_item(self, item_id: str, collection_id: str, refresh: bool = False): """Delete a single item from the database. Args: @@ -1075,9 +1041,7 @@ async def delete_item( refresh=refresh, ) except exceptions.NotFoundError: - raise NotFoundError( - f"Item {item_id} in collection {collection_id} not found" - ) + raise NotFoundError(f"Item {item_id} in collection {collection_id} not found") async def create_collection(self, collection: Collection, refresh: bool = False): """Create a single collection in the database. @@ -1124,17 +1088,13 @@ async def find_collection(self, collection_id: str) -> Collection: collection as a `Collection` object. If the collection is not found, a `NotFoundError` is raised. """ try: - collection = await self.client.get( - index=COLLECTIONS_INDEX, id=collection_id - ) + collection = await self.client.get(index=COLLECTIONS_INDEX, id=collection_id) except exceptions.NotFoundError: raise NotFoundError(f"Collection {collection_id} not found") return collection["_source"] - async def update_collection( - self, collection_id: str, collection: Collection, refresh: bool = False - ): + async def update_collection(self, collection_id: str, collection: Collection, refresh: bool = False): """Update a collection from the database. Args: @@ -1254,9 +1214,7 @@ async def json_patch_collection( if new_collection_id: collection["id"] = new_collection_id collection["links"] = resolve_links([], base_url) - await self.update_collection( - collection_id=collection_id, collection=collection, refresh=False - ) + await self.update_collection(collection_id=collection_id, collection=collection, refresh=False) return collection @@ -1277,14 +1235,10 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): function also calls `delete_item_index` to delete the index for the items in the collection. """ await self.find_collection(collection_id=collection_id) - await self.client.delete( - index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh - ) + await self.client.delete(index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh) await delete_item_index(collection_id) - async def bulk_async( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: + async def bulk_async(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: """Perform a bulk insert of items into the database asynchronously. Args: @@ -1306,9 +1260,7 @@ async def bulk_async( raise_on_error=False, ) - def bulk_sync( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: + def bulk_sync(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: """Perform a bulk insert of items into the database synchronously. Args: diff --git a/stac_fastapi/tests/api/test_api.py b/stac_fastapi/tests/api/test_api.py index 64545807..d67236ae 100644 --- a/stac_fastapi/tests/api/test_api.py +++ b/stac_fastapi/tests/api/test_api.py @@ -28,7 +28,9 @@ "POST /collections", "POST /collections/{collection_id}/items", "PUT /collections/{collection_id}", + "PATCH /collections/{collection_id}", "PUT /collections/{collection_id}/items/{item_id}", + "PATCH /collections/{collection_id}/items/{item_id}", "GET /aggregations", "GET /aggregate", "POST /aggregations", From 90a287f07391bc5deb27cd8893a23f0f6a0fcd8d Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 12:27:36 +0000 Subject: [PATCH 12/38] pre-commit. --- .../stac_fastapi/opensearch/database_logic.py | 102 +++++++++++++----- 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 88cee988..18fa3e20 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -273,7 +273,9 @@ async def create_item_index(collection_id: str): } try: - await client.indices.create(index=f"{index_by_collection_id(collection_id)}-000001", body=search_body) + await client.indices.create( + index=f"{index_by_collection_id(collection_id)}-000001", body=search_body + ) except TransportError as e: if e.status_code == 400: pass # Ignore 400 status codes @@ -354,8 +356,12 @@ class DatabaseLogic: client = AsyncSearchSettings().create_client sync_client = SyncSearchSettings().create_client - item_serializer: Type[serializers.ItemSerializer] = attr.ib(default=serializers.ItemSerializer) - collection_serializer: Type[serializers.CollectionSerializer] = attr.ib(default=serializers.CollectionSerializer) + item_serializer: Type[serializers.ItemSerializer] = attr.ib( + default=serializers.ItemSerializer + ) + collection_serializer: Type[serializers.CollectionSerializer] = attr.ib( + default=serializers.CollectionSerializer + ) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -389,9 +395,15 @@ class DatabaseLogic: "size": 10000, } }, - "sun_elevation_frequency": {"histogram": {"field": "properties.view:sun_elevation", "interval": 5}}, - "sun_azimuth_frequency": {"histogram": {"field": "properties.view:sun_azimuth", "interval": 5}}, - "off_nadir_frequency": {"histogram": {"field": "properties.view:off_nadir", "interval": 5}}, + "sun_elevation_frequency": { + "histogram": {"field": "properties.view:sun_elevation", "interval": 5} + }, + "sun_azimuth_frequency": { + "histogram": {"field": "properties.view:sun_azimuth", "interval": 5} + }, + "off_nadir_frequency": { + "histogram": {"field": "properties.view:off_nadir", "interval": 5} + }, "centroid_geohash_grid_frequency": { "geohash_grid": { "field": "properties.proj:centroid", @@ -494,7 +506,9 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: id=mk_item_id(item_id, collection_id), ) except exceptions.NotFoundError: - raise NotFoundError(f"Item {item_id} does not exist in Collection {collection_id}") + raise NotFoundError( + f"Item {item_id} does not exist in Collection {collection_id}" + ) return item["_source"] @staticmethod @@ -517,7 +531,9 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] """Database logic to perform query for search endpoint.""" if free_text_queries is not None: free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) - search = search.query("query_string", query=f'properties.\\*:"{free_text_query_string}"') + search = search.query( + "query_string", query=f'properties.\\*:"{free_text_query_string}"' + ) return search @@ -533,10 +549,16 @@ def apply_datetime_filter(search: Search, datetime_search): Search: The filtered search object. """ if "eq" in datetime_search: - search = search.filter("term", **{"properties__datetime": datetime_search["eq"]}) + search = search.filter( + "term", **{"properties__datetime": datetime_search["eq"]} + ) else: - search = search.filter("range", properties__datetime={"lte": datetime_search["lte"]}) - search = search.filter("range", properties__datetime={"gte": datetime_search["gte"]}) + search = search.filter( + "range", properties__datetime={"lte": datetime_search["lte"]} + ) + search = search.filter( + "range", properties__datetime={"gte": datetime_search["gte"]} + ) return search @staticmethod @@ -739,7 +761,11 @@ async def execute_search( if hits and (sort_array := hits[limit - 1].get("sort")): next_token = urlsafe_b64encode(json.dumps(sort_array).encode()).decode() - matched = es_response["hits"]["total"]["value"] if es_response["hits"]["total"]["relation"] == "eq" else None + matched = ( + es_response["hits"]["total"]["value"] + if es_response["hits"]["total"]["relation"] == "eq" + else None + ) if count_task.done(): try: matched = count_task.result().get("count") @@ -817,7 +843,9 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: + async def prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: """ Preps an item for insertion into the database. @@ -839,11 +867,15 @@ async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = Fal index=index_alias_by_collection_id(item["collection"]), id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError(f"Item {item['id']} in collection {item['collection']} already exists") + raise ConflictError( + f"Item {item['id']} in collection {item['collection']} already exists" + ) return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: + def sync_prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: """ Prepare an item for insertion into the database. @@ -872,7 +904,9 @@ def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = Fals index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), ): - raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") + raise ConflictError( + f"Item {item_id} in collection {collection_id} already exists" + ) return self.item_serializer.stac_to_db(item, base_url) @@ -900,7 +934,9 @@ async def create_item(self, item: Item, refresh: bool = False): ) if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") + raise ConflictError( + f"Item {item_id} in collection {collection_id} already exists" + ) async def merge_patch_item( self, @@ -1023,7 +1059,9 @@ async def json_patch_item( return item - async def delete_item(self, item_id: str, collection_id: str, refresh: bool = False): + async def delete_item( + self, item_id: str, collection_id: str, refresh: bool = False + ): """Delete a single item from the database. Args: @@ -1041,7 +1079,9 @@ async def delete_item(self, item_id: str, collection_id: str, refresh: bool = Fa refresh=refresh, ) except exceptions.NotFoundError: - raise NotFoundError(f"Item {item_id} in collection {collection_id} not found") + raise NotFoundError( + f"Item {item_id} in collection {collection_id} not found" + ) async def create_collection(self, collection: Collection, refresh: bool = False): """Create a single collection in the database. @@ -1088,13 +1128,17 @@ async def find_collection(self, collection_id: str) -> Collection: collection as a `Collection` object. If the collection is not found, a `NotFoundError` is raised. """ try: - collection = await self.client.get(index=COLLECTIONS_INDEX, id=collection_id) + collection = await self.client.get( + index=COLLECTIONS_INDEX, id=collection_id + ) except exceptions.NotFoundError: raise NotFoundError(f"Collection {collection_id} not found") return collection["_source"] - async def update_collection(self, collection_id: str, collection: Collection, refresh: bool = False): + async def update_collection( + self, collection_id: str, collection: Collection, refresh: bool = False + ): """Update a collection from the database. Args: @@ -1214,7 +1258,9 @@ async def json_patch_collection( if new_collection_id: collection["id"] = new_collection_id collection["links"] = resolve_links([], base_url) - await self.update_collection(collection_id=collection_id, collection=collection, refresh=False) + await self.update_collection( + collection_id=collection_id, collection=collection, refresh=False + ) return collection @@ -1235,10 +1281,14 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): function also calls `delete_item_index` to delete the index for the items in the collection. """ await self.find_collection(collection_id=collection_id) - await self.client.delete(index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh) + await self.client.delete( + index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh + ) await delete_item_index(collection_id) - async def bulk_async(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: + async def bulk_async( + self, collection_id: str, processed_items: List[Item], refresh: bool = False + ) -> None: """Perform a bulk insert of items into the database asynchronously. Args: @@ -1260,7 +1310,9 @@ async def bulk_async(self, collection_id: str, processed_items: List[Item], refr raise_on_error=False, ) - def bulk_sync(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: + def bulk_sync( + self, collection_id: str, processed_items: List[Item], refresh: bool = False + ) -> None: """Perform a bulk insert of items into the database synchronously. Args: From f1c320a74f88b9c6afebfe3361d2052c8bc20b42 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 26 Mar 2025 12:45:32 +0000 Subject: [PATCH 13/38] Switch to model validator. --- .../core/stac_fastapi/core/models/patch.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index 91a76b90..03519d57 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -1,8 +1,8 @@ """patch helpers.""" -from typing import Optional +from typing import Any, Optional -from pydantic import BaseModel, computed_field +from pydantic import BaseModel, computed_field, model_validator class ElasticPath(BaseModel): @@ -19,20 +19,22 @@ class ElasticPath(BaseModel): key: Optional[str] = None index: Optional[int] = None - def __init__(self, *, path: str): - """Convert JSON path to Elasticsearch script path. + @model_validator(mode="before") + @classmethod + def validate_model(cls, data: Any): + """Set optional fields from JSON path. Args: - path (str): initial JSON path + data (Any): input data """ - self.path = path.lstrip("/").replace("/", ".") + data["path"] = data["path"].lstrip("/").replace("/", ".") - self.nest, self.partition, self.key = path.rpartition(".") + data["nest"], data["partition"], data["key"] = data["path"].rpartition(".") - if self.key.isdigit(): - self.index = int(self.key) - self.path = f"{self.nest}[{self.index}]" - self.nest, self.partition, self.key = self.nest.rpartition(".") + if data["key"].isdigit(): + data["index"] = int(data["key"]) + data["path"] = f"{data['nest']}[{data['index']}]" + data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".") @computed_field # type: ignore[misc] @property From a2d6f48c898b6f4520543d5c8500016d8896759d Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 27 Mar 2025 15:13:15 +0000 Subject: [PATCH 14/38] Simplify conversion logic. --- .../core/stac_fastapi/core/models/patch.py | 2 + .../core/stac_fastapi/core/utilities.py | 203 +++++++++++++----- .../elasticsearch/database_logic.py | 2 +- 3 files changed, 152 insertions(+), 55 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index 03519d57..af2fb39a 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -36,6 +36,8 @@ def validate_model(cls, data: Any): data["path"] = f"{data['nest']}[{data['index']}]" data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".") + return data + @computed_field # type: ignore[misc] @property def location(self) -> str: diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index d744d9ec..b3024b9c 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -4,10 +4,16 @@ such as converting bounding boxes to polygon representations. """ +import re from typing import Any, Dict, List, Optional, Set, Union from stac_fastapi.core.models.patch import ElasticPath -from stac_fastapi.types.stac import Item, PatchAddReplaceTest, PatchRemove +from stac_fastapi.types.stac import ( + Item, + PatchAddReplaceTest, + PatchOperation, + PatchRemove, +) MAX_LIMIT = 10000 @@ -166,29 +172,149 @@ def merge_to_operations(data: Dict) -> List: return operations -def add_script_checks(source: str, op: str, path: ElasticPath) -> str: +def check_commands( + commands: List[str], + op: str, + path: ElasticPath, + from_path: bool = False, +) -> None: """Add Elasticsearch checks to operation. Args: - source (str): current source of Elasticsearch script + commands (List[str]): current commands op (str): the operation of script path (Dict): path of variable to run operation on + from_path (bool): True if path is a from path - Returns: - Dict: update source of Elasticsearch script """ if path.nest: - source += ( + commands.append( f"if (!ctx._source.containsKey('{path.nest}'))" f"{{Debug.explain('{path.nest} does not exist');}}" ) - if path.index or op != "add": - source += ( + if path.index or op in ["remove", "replace", "test"] or from_path: + commands.append( f"if (!ctx._source.{path.nest}.containsKey('{path.key}'))" - f"{{Debug.explain('{path.path} does not exist');}}" + f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}" + ) + + +def copy_commands( + commands: List[str], + operation: PatchOperation, + path: ElasticPath, + from_path: ElasticPath, +) -> None: + """Copy value from path to from path. + + Args: + commands (List[str]): current commands + operation (PatchOperation): Operation to be converted + op_path (ElasticPath): Path to copy to + from_path (ElasticPath): Path to copy from + + """ + check_commands(operation.op, from_path, True) + + if from_path.index: + commands.append( + f"if ((ctx._source.{from_path.location} instanceof ArrayList" + f" && ctx._source.{from_path.location}.size() < {from_path.index})" + f" || (!ctx._source.{from_path.location}.containsKey('{from_path.index}'))" + f"{{Debug.explain('{from_path.path} does not exist');}}" + ) + + if path.index: + commands.append( + f"if (ctx._source.{path.location} instanceof ArrayList)" + f"{{ctx._source.{path.location}.add({path.index}, {from_path.path})}}" + f"else{{ctx._source.{path.path} = {from_path.path}}}" + ) + + else: + commands.append(f"ctx._source.{path.path} = ctx._source.{from_path.path};") + + +def remove_commands(commands: List[str], path: ElasticPath) -> None: + """Remove value at path. + + Args: + commands (List[str]): current commands + path (ElasticPath): Path to value to be removed + + """ + if path.index: + commands.append(f"ctx._source.{path.location}.remove('{path.index}');") + + else: + commands.append(f"ctx._source.{path.nest}.remove('{path.key}');") + + +def add_commands( + commands: List[str], operation: PatchOperation, path: ElasticPath +) -> None: + """Add value at path. + + Args: + commands (List[str]): current commands + operation (PatchOperation): operation to run + path (ElasticPath): path for value to be added + + """ + if path.index: + commands.append( + f"if (ctx._source.{path.location} instanceof ArrayList)" + f"{{ctx._source.{path.location}.add({path.index}, {operation.json_value})}}" + f"else{{ctx._source.{path.path} = {operation.json_value}}}" ) + else: + commands.append(f"ctx._source.{path.path} = {operation.json_value};") + + +def test_commands( + commands: List[str], operation: PatchOperation, path: ElasticPath +) -> None: + """Test value at path. + + Args: + commands (List[str]): current commands + operation (PatchOperation): operation to run + path (ElasticPath): path for value to be tested + """ + commands.append( + f"if (ctx._source.{path.location} != {operation.json_value})" + f"{{Debug.explain('Test failed for: {path.path} | " + f"{operation.json_value} != ' + ctx._source.{path.location});}}" + ) + + +def commands_to_source(commands: List[str]) -> str: + """Convert list of commands to Elasticsearch script source. + + Args: + commands (List[str]): List of Elasticearch commands + + Returns: + str: Elasticsearch script source + """ + seen: Set[str] = set() + seen_add = seen.add + regex = re.compile(r"([^.' ]*:[^.' ]*)[. ]") + source = "" + + # filter duplicate lines + for command in commands: + if command not in seen: + seen_add(command) + # extension terms with using `:` must be swapped out + if matches := regex.findall(command): + for match in matches: + command = command.replace(f".{match}", f"['{match}']") + + source += command + return source @@ -201,60 +327,29 @@ def operations_to_script(operations: List) -> Dict: Returns: Dict: elasticsearch update script. """ - source = "" + commands: List = [] for operation in operations: - op_path = ElasticPath(path=operation.path) - source = add_script_checks(source, operation.op, op_path) - - if hasattr(operation, "from"): - from_path = ElasticPath(path=(getattr(operation, "from"))) - source = add_script_checks(source, operation.op, from_path) - if from_path.index: - source += ( - f"if ((ctx._source.{from_path.location} instanceof ArrayList" - f" && ctx._source.{from_path.location}.size() < {from_path.index})" - f" || (!ctx._source.{from_path.location}.containsKey('{from_path.index}'))" - f"{{Debug.explain('{from_path.path} does not exist');}}" - ) + path = ElasticPath(path=operation.path) + from_path = ( + ElasticPath(path=operation.from_) if hasattr(operation, "from_") else None + ) - if operation.op in ["copy", "move"]: - if op_path.index: - source += ( - f"if (ctx._source.{op_path.location} instanceof ArrayList)" - f"{{ctx._source.{op_path.location}.add({op_path.index}, {from_path.path})}}" - f"else{{ctx._source.{op_path.path} = {from_path.path}}}" - ) + check_commands(commands, operation.op, path) - else: - source += f"ctx._source.{op_path.path} = ctx._source.{from_path.path};" + if operation.op in ["copy", "move"]: + copy_commands(commands, operation, path, from_path) if operation.op in ["remove", "move"]: - remove_path = from_path if operation.op == "move" else op_path - - if remove_path.index: - source += ( - f"ctx._source.{remove_path.location}.remove('{remove_path.index}');" - ) - - else: - source += f"ctx._source.remove('{remove_path.location}');" + remove_path = from_path if from_path else path + remove_commands(commands, remove_path) if operation.op in ["add", "replace"]: - if op_path["index"]: - source += ( - f"if (ctx._source.{op_path.location} instanceof ArrayList)" - f"{{ctx._source.{op_path.location}.add({op_path.index}, {operation.json_value})}}" - f"else{{ctx._source.{op_path.path} = {operation.json_value}}}" - ) - - else: - source += f"ctx._source.{op_path.path} = {operation.json_value};" + add_commands(commands, operation, path) if operation.op == "test": - source += ( - f"if (ctx._source.{op_path.location} != {operation.json_value})" - f"{{Debug.explain('Test failed for: {op_path.path} | {operation.json_value} != ctx._source.{op_path.location}');}}" - ) + test_commands(commands, operation, path) + + source = commands_to_source(commands) return { "source": source, diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 9b743dcc..58f2ff5e 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -980,7 +980,7 @@ async def json_patch_item( try: await self.client.update( - index=index_by_collection_id(collection_id), + index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), script=script, refresh=True, From 3a75b68d305ec48b79e79c2cb59fea4b1383487a Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 27 Mar 2025 16:00:30 +0000 Subject: [PATCH 15/38] Opensearch update body not script. --- .../stac_fastapi/opensearch/database_logic.py | 104 +++------ .../tests/clients/test_elasticsearch.py | 221 ++++++++++++------ 2 files changed, 176 insertions(+), 149 deletions(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 18fa3e20..0f8e923e 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -273,9 +273,7 @@ async def create_item_index(collection_id: str): } try: - await client.indices.create( - index=f"{index_by_collection_id(collection_id)}-000001", body=search_body - ) + await client.indices.create(index=f"{index_by_collection_id(collection_id)}-000001", body=search_body) except TransportError as e: if e.status_code == 400: pass # Ignore 400 status codes @@ -356,12 +354,8 @@ class DatabaseLogic: client = AsyncSearchSettings().create_client sync_client = SyncSearchSettings().create_client - item_serializer: Type[serializers.ItemSerializer] = attr.ib( - default=serializers.ItemSerializer - ) - collection_serializer: Type[serializers.CollectionSerializer] = attr.ib( - default=serializers.CollectionSerializer - ) + item_serializer: Type[serializers.ItemSerializer] = attr.ib(default=serializers.ItemSerializer) + collection_serializer: Type[serializers.CollectionSerializer] = attr.ib(default=serializers.CollectionSerializer) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -395,15 +389,9 @@ class DatabaseLogic: "size": 10000, } }, - "sun_elevation_frequency": { - "histogram": {"field": "properties.view:sun_elevation", "interval": 5} - }, - "sun_azimuth_frequency": { - "histogram": {"field": "properties.view:sun_azimuth", "interval": 5} - }, - "off_nadir_frequency": { - "histogram": {"field": "properties.view:off_nadir", "interval": 5} - }, + "sun_elevation_frequency": {"histogram": {"field": "properties.view:sun_elevation", "interval": 5}}, + "sun_azimuth_frequency": {"histogram": {"field": "properties.view:sun_azimuth", "interval": 5}}, + "off_nadir_frequency": {"histogram": {"field": "properties.view:off_nadir", "interval": 5}}, "centroid_geohash_grid_frequency": { "geohash_grid": { "field": "properties.proj:centroid", @@ -506,9 +494,7 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: id=mk_item_id(item_id, collection_id), ) except exceptions.NotFoundError: - raise NotFoundError( - f"Item {item_id} does not exist in Collection {collection_id}" - ) + raise NotFoundError(f"Item {item_id} does not exist in Collection {collection_id}") return item["_source"] @staticmethod @@ -531,9 +517,7 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] """Database logic to perform query for search endpoint.""" if free_text_queries is not None: free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) - search = search.query( - "query_string", query=f'properties.\\*:"{free_text_query_string}"' - ) + search = search.query("query_string", query=f'properties.\\*:"{free_text_query_string}"') return search @@ -549,16 +533,10 @@ def apply_datetime_filter(search: Search, datetime_search): Search: The filtered search object. """ if "eq" in datetime_search: - search = search.filter( - "term", **{"properties__datetime": datetime_search["eq"]} - ) + search = search.filter("term", **{"properties__datetime": datetime_search["eq"]}) else: - search = search.filter( - "range", properties__datetime={"lte": datetime_search["lte"]} - ) - search = search.filter( - "range", properties__datetime={"gte": datetime_search["gte"]} - ) + search = search.filter("range", properties__datetime={"lte": datetime_search["lte"]}) + search = search.filter("range", properties__datetime={"gte": datetime_search["gte"]}) return search @staticmethod @@ -761,11 +739,7 @@ async def execute_search( if hits and (sort_array := hits[limit - 1].get("sort")): next_token = urlsafe_b64encode(json.dumps(sort_array).encode()).decode() - matched = ( - es_response["hits"]["total"]["value"] - if es_response["hits"]["total"]["relation"] == "eq" - else None - ) + matched = es_response["hits"]["total"]["value"] if es_response["hits"]["total"]["relation"] == "eq" else None if count_task.done(): try: matched = count_task.result().get("count") @@ -843,9 +817,7 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item( - self, item: Item, base_url: str, exist_ok: bool = False - ) -> Item: + async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: """ Preps an item for insertion into the database. @@ -867,15 +839,11 @@ async def prep_create_item( index=index_alias_by_collection_id(item["collection"]), id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError( - f"Item {item['id']} in collection {item['collection']} already exists" - ) + raise ConflictError(f"Item {item['id']} in collection {item['collection']} already exists") return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item( - self, item: Item, base_url: str, exist_ok: bool = False - ) -> Item: + def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: """ Prepare an item for insertion into the database. @@ -904,9 +872,7 @@ def sync_prep_create_item( index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), ): - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) + raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") return self.item_serializer.stac_to_db(item, base_url) @@ -934,9 +900,7 @@ async def create_item(self, item: Item, refresh: bool = False): ) if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError( - f"Item {item_id} in collection {collection_id} already exists" - ) + raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") async def merge_patch_item( self, @@ -1059,9 +1023,7 @@ async def json_patch_item( return item - async def delete_item( - self, item_id: str, collection_id: str, refresh: bool = False - ): + async def delete_item(self, item_id: str, collection_id: str, refresh: bool = False): """Delete a single item from the database. Args: @@ -1079,9 +1041,7 @@ async def delete_item( refresh=refresh, ) except exceptions.NotFoundError: - raise NotFoundError( - f"Item {item_id} in collection {collection_id} not found" - ) + raise NotFoundError(f"Item {item_id} in collection {collection_id} not found") async def create_collection(self, collection: Collection, refresh: bool = False): """Create a single collection in the database. @@ -1128,17 +1088,13 @@ async def find_collection(self, collection_id: str) -> Collection: collection as a `Collection` object. If the collection is not found, a `NotFoundError` is raised. """ try: - collection = await self.client.get( - index=COLLECTIONS_INDEX, id=collection_id - ) + collection = await self.client.get(index=COLLECTIONS_INDEX, id=collection_id) except exceptions.NotFoundError: raise NotFoundError(f"Collection {collection_id} not found") return collection["_source"] - async def update_collection( - self, collection_id: str, collection: Collection, refresh: bool = False - ): + async def update_collection(self, collection_id: str, collection: Collection, refresh: bool = False): """Update a collection from the database. Args: @@ -1249,7 +1205,7 @@ async def json_patch_collection( await self.client.update( index=COLLECTIONS_INDEX, id=collection_id, - script=script, + body={"script": script}, refresh=refresh, ) @@ -1258,9 +1214,7 @@ async def json_patch_collection( if new_collection_id: collection["id"] = new_collection_id collection["links"] = resolve_links([], base_url) - await self.update_collection( - collection_id=collection_id, collection=collection, refresh=False - ) + await self.update_collection(collection_id=collection_id, collection=collection, refresh=False) return collection @@ -1281,14 +1235,10 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): function also calls `delete_item_index` to delete the index for the items in the collection. """ await self.find_collection(collection_id=collection_id) - await self.client.delete( - index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh - ) + await self.client.delete(index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh) await delete_item_index(collection_id) - async def bulk_async( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: + async def bulk_async(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: """Perform a bulk insert of items into the database asynchronously. Args: @@ -1310,9 +1260,7 @@ async def bulk_async( raise_on_error=False, ) - def bulk_sync( - self, collection_id: str, processed_items: List[Item], refresh: bool = False - ) -> None: + def bulk_sync(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: """Perform a bulk insert of items into the database synchronously. Args: diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 6c216605..67e8999f 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -43,9 +43,7 @@ async def test_update_collection( collection_data = load_test_data("test_collection.json") item_data = load_test_data("test_item.json") - await txn_client.create_collection( - api.Collection(**collection_data), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -54,9 +52,7 @@ async def test_update_collection( ) collection_data["keywords"].append("new keyword") - await txn_client.update_collection( - collection_data["id"], api.Collection(**collection_data), request=MockRequest - ) + await txn_client.update_collection(collection_data["id"], api.Collection(**collection_data), request=MockRequest) coll = await core_client.get_collection(collection_data["id"], request=MockRequest) assert "new keyword" in coll["keywords"] @@ -83,9 +79,7 @@ async def test_update_collection_id( item_data = load_test_data("test_item.json") new_collection_id = "new-test-collection" - await txn_client.create_collection( - api.Collection(**collection_data), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -197,14 +191,10 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): @pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): - resp = await core_client.get_item( - ctx.item["id"], ctx.item["collection"], request=MockRequest - ) - assert Item(**ctx.item).model_dump( - exclude={"links": ..., "properties": {"created", "updated"}} - ) == Item(**resp).model_dump( - exclude={"links": ..., "properties": {"created", "updated"}} - ) + resp = await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) + assert Item(**ctx.item).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) == Item( + **resp + ).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) @pytest.mark.asyncio @@ -231,43 +221,72 @@ async def test_update_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + assert updated_item["properties"]["foo"] == "bar" + + +@pytest.mark.asyncio +async def test_merge_patch_item_add(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + await txn_client.merge_patch_item( + collection_id=collection_id, + item_id=item_id, + item={"properties": {"foo": "bar", "hello": "world"}}, + request=MockRequest, ) + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == "bar" + assert updated_item["properties"]["hello"] == "world" @pytest.mark.asyncio -async def test_merge_patch_item(ctx, core_client, txn_client): +async def test_merge_patch_item_remove(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] await txn_client.merge_patch_item( collection_id=collection_id, item_id=item_id, - item={"properties": {"foo": "bar", "gsd": None}}, + item={"properties": {"foo": None, "hello": None}}, request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + assert "foo" not in updated_item["properties"] + assert "hello" not in updated_item["properties"] + + +@pytest.mark.asyncio +async def test_json_patch_item_add(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "add", "path": "properties.foo", "value": "bar"}, + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, ) - assert updated_item["properties"]["foo"] == "bar" - assert "gsd" not in updated_item["properties"] + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + assert updated_item["properties"]["bar"] == "foo" @pytest.mark.asyncio -async def test_json_patch_item(ctx, core_client, txn_client): +async def test_json_patch_item_replace(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "add", "path": "properties.bar", "value": "foo"}, - {"op": "remove", "path": "properties.instrument"}, - {"op": "replace", "path": "properties.width", "value": 100}, - {"op": "test", "path": "properties.platform", "value": "landsat-8"}, - {"op": "move", "path": "properties.hello", "from": "properties.height"}, - {"op": "copy", "path": "properties.world", "from": "properties.proj:epsg"}, + {"op": "replace", "path": "properties.foo", "value": 100}, ] await txn_client.json_patch_item( @@ -277,22 +296,96 @@ async def test_json_patch_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + assert updated_item["properties"]["foo"] == 100 + + +@pytest.mark.asyncio +async def test_json_patch_item_test(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "test", "path": "properties.foo", "value": 100}, + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, ) - # add foo - assert updated_item["properties"]["bar"] == "foo" - # remove gsd - assert "instrument" not in updated_item["properties"] - # replace width - assert updated_item["properties"]["width"] == 100 - # move height - assert updated_item["properties"]["hello"] == item["properties"]["height"] - assert "height" not in updated_item["properties"] - # copy proj:epsg - assert updated_item["properties"]["world"] == item["properties"]["proj:epsg"] - assert updated_item["properties"]["proj:epsg"] == item["properties"]["proj:epsg"] + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + assert updated_item["properties"]["foo"] == 100 + + +@pytest.mark.asyncio +async def test_json_patch_item_move(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "move", "path": "properties.bar", "from": "properties.foo"}, + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + assert updated_item["properties"]["bar"] == 100 + assert "foo" not in updated_item["properties"] + + +@pytest.mark.asyncio +async def test_json_patch_item_copy(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "copy", "path": "properties.foo", "from": "properties.bar"}, + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + assert updated_item["properties"]["foo"] == updated_item["properties"]["bar"] + + +@pytest.mark.asyncio +async def test_json_patch_item_copy(ctx, core_client, txn_client): + item = ctx.item + collection_id = item["collection"] + item_id = item["id"] + operations = [ + {"op": "remove", "path": "properties.foo"}, + {"op": "remove", "path": "properties.bar"}, + ] + + await txn_client.json_patch_item( + collection_id=collection_id, + item_id=item_id, + operations=operations, + request=MockRequest, + ) + + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + + assert "foo" not in updated_item["properties"] + assert "bar" not in updated_item["properties"] @pytest.mark.asyncio @@ -315,14 +408,12 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): @pytest.mark.asyncio -async def test_json_patch_item_replace_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "replace", "path": "properties.platforms", "value": "landsat-9"}, + {"op": "replace", "path": "properties.foo", "value": "landsat-9"}, ] with pytest.raises(ConflictError): @@ -336,14 +427,12 @@ async def test_json_patch_item_replace_property_does_not_exists( @pytest.mark.asyncio -async def test_json_patch_item_remove_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "remove", "path": "properties.platforms"}, + {"op": "remove", "path": "properties.foo"}, ] with pytest.raises(ConflictError): @@ -357,14 +446,12 @@ async def test_json_patch_item_remove_property_does_not_exists( @pytest.mark.asyncio -async def test_json_patch_item_move_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "move", "path": "properties.platformed", "from": "properties.platforms"}, + {"op": "move", "path": "properties.bar", "from": "properties.foo"}, ] with pytest.raises(ConflictError): @@ -378,14 +465,12 @@ async def test_json_patch_item_move_property_does_not_exists( @pytest.mark.asyncio -async def test_json_patch_item_copy_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_copy_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "copy", "path": "properties.platformed", "from": "properties.platforms"}, + {"op": "copy", "path": "properties.bar", "from": "properties.foo"}, ] with pytest.raises(ConflictError): @@ -420,9 +505,7 @@ async def test_update_geometry(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["geometry"]["coordinates"] == new_coordinates @@ -431,9 +514,7 @@ async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) with pytest.raises(NotFoundError): - await core_client.get_item( - ctx.item["id"], ctx.item["collection"], request=MockRequest - ) + await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) @pytest.mark.asyncio @@ -482,9 +563,7 @@ async def test_feature_collection_insert( async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] - await txn_client.create_collection( - api.Collection(**ctx.collection), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**ctx.collection), request=MockRequest) landing_page = await core_client.landing_page(request=MockRequest(app=app)) for link in landing_page["links"]: From ee9032479edd574f4a5c5a8bb19a983ff3f8e7e9 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 27 Mar 2025 16:03:58 +0000 Subject: [PATCH 16/38] pre-commit. --- .../stac_fastapi/opensearch/database_logic.py | 102 +++++++++++++----- .../tests/clients/test_elasticsearch.py | 88 +++++++++++---- 2 files changed, 142 insertions(+), 48 deletions(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 0f8e923e..6949bd06 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -273,7 +273,9 @@ async def create_item_index(collection_id: str): } try: - await client.indices.create(index=f"{index_by_collection_id(collection_id)}-000001", body=search_body) + await client.indices.create( + index=f"{index_by_collection_id(collection_id)}-000001", body=search_body + ) except TransportError as e: if e.status_code == 400: pass # Ignore 400 status codes @@ -354,8 +356,12 @@ class DatabaseLogic: client = AsyncSearchSettings().create_client sync_client = SyncSearchSettings().create_client - item_serializer: Type[serializers.ItemSerializer] = attr.ib(default=serializers.ItemSerializer) - collection_serializer: Type[serializers.CollectionSerializer] = attr.ib(default=serializers.CollectionSerializer) + item_serializer: Type[serializers.ItemSerializer] = attr.ib( + default=serializers.ItemSerializer + ) + collection_serializer: Type[serializers.CollectionSerializer] = attr.ib( + default=serializers.CollectionSerializer + ) extensions: List[str] = attr.ib(default=attr.Factory(list)) @@ -389,9 +395,15 @@ class DatabaseLogic: "size": 10000, } }, - "sun_elevation_frequency": {"histogram": {"field": "properties.view:sun_elevation", "interval": 5}}, - "sun_azimuth_frequency": {"histogram": {"field": "properties.view:sun_azimuth", "interval": 5}}, - "off_nadir_frequency": {"histogram": {"field": "properties.view:off_nadir", "interval": 5}}, + "sun_elevation_frequency": { + "histogram": {"field": "properties.view:sun_elevation", "interval": 5} + }, + "sun_azimuth_frequency": { + "histogram": {"field": "properties.view:sun_azimuth", "interval": 5} + }, + "off_nadir_frequency": { + "histogram": {"field": "properties.view:off_nadir", "interval": 5} + }, "centroid_geohash_grid_frequency": { "geohash_grid": { "field": "properties.proj:centroid", @@ -494,7 +506,9 @@ async def get_one_item(self, collection_id: str, item_id: str) -> Dict: id=mk_item_id(item_id, collection_id), ) except exceptions.NotFoundError: - raise NotFoundError(f"Item {item_id} does not exist in Collection {collection_id}") + raise NotFoundError( + f"Item {item_id} does not exist in Collection {collection_id}" + ) return item["_source"] @staticmethod @@ -517,7 +531,9 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] """Database logic to perform query for search endpoint.""" if free_text_queries is not None: free_text_query_string = '" OR properties.\\*:"'.join(free_text_queries) - search = search.query("query_string", query=f'properties.\\*:"{free_text_query_string}"') + search = search.query( + "query_string", query=f'properties.\\*:"{free_text_query_string}"' + ) return search @@ -533,10 +549,16 @@ def apply_datetime_filter(search: Search, datetime_search): Search: The filtered search object. """ if "eq" in datetime_search: - search = search.filter("term", **{"properties__datetime": datetime_search["eq"]}) + search = search.filter( + "term", **{"properties__datetime": datetime_search["eq"]} + ) else: - search = search.filter("range", properties__datetime={"lte": datetime_search["lte"]}) - search = search.filter("range", properties__datetime={"gte": datetime_search["gte"]}) + search = search.filter( + "range", properties__datetime={"lte": datetime_search["lte"]} + ) + search = search.filter( + "range", properties__datetime={"gte": datetime_search["gte"]} + ) return search @staticmethod @@ -739,7 +761,11 @@ async def execute_search( if hits and (sort_array := hits[limit - 1].get("sort")): next_token = urlsafe_b64encode(json.dumps(sort_array).encode()).decode() - matched = es_response["hits"]["total"]["value"] if es_response["hits"]["total"]["relation"] == "eq" else None + matched = ( + es_response["hits"]["total"]["value"] + if es_response["hits"]["total"]["relation"] == "eq" + else None + ) if count_task.done(): try: matched = count_task.result().get("count") @@ -817,7 +843,9 @@ async def check_collection_exists(self, collection_id: str): if not await self.client.exists(index=COLLECTIONS_INDEX, id=collection_id): raise NotFoundError(f"Collection {collection_id} does not exist") - async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: + async def prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: """ Preps an item for insertion into the database. @@ -839,11 +867,15 @@ async def prep_create_item(self, item: Item, base_url: str, exist_ok: bool = Fal index=index_alias_by_collection_id(item["collection"]), id=mk_item_id(item["id"], item["collection"]), ): - raise ConflictError(f"Item {item['id']} in collection {item['collection']} already exists") + raise ConflictError( + f"Item {item['id']} in collection {item['collection']} already exists" + ) return self.item_serializer.stac_to_db(item, base_url) - def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = False) -> Item: + def sync_prep_create_item( + self, item: Item, base_url: str, exist_ok: bool = False + ) -> Item: """ Prepare an item for insertion into the database. @@ -872,7 +904,9 @@ def sync_prep_create_item(self, item: Item, base_url: str, exist_ok: bool = Fals index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), ): - raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") + raise ConflictError( + f"Item {item_id} in collection {collection_id} already exists" + ) return self.item_serializer.stac_to_db(item, base_url) @@ -900,7 +934,9 @@ async def create_item(self, item: Item, refresh: bool = False): ) if (meta := es_resp.get("meta")) and meta.get("status") == 409: - raise ConflictError(f"Item {item_id} in collection {collection_id} already exists") + raise ConflictError( + f"Item {item_id} in collection {collection_id} already exists" + ) async def merge_patch_item( self, @@ -1023,7 +1059,9 @@ async def json_patch_item( return item - async def delete_item(self, item_id: str, collection_id: str, refresh: bool = False): + async def delete_item( + self, item_id: str, collection_id: str, refresh: bool = False + ): """Delete a single item from the database. Args: @@ -1041,7 +1079,9 @@ async def delete_item(self, item_id: str, collection_id: str, refresh: bool = Fa refresh=refresh, ) except exceptions.NotFoundError: - raise NotFoundError(f"Item {item_id} in collection {collection_id} not found") + raise NotFoundError( + f"Item {item_id} in collection {collection_id} not found" + ) async def create_collection(self, collection: Collection, refresh: bool = False): """Create a single collection in the database. @@ -1088,13 +1128,17 @@ async def find_collection(self, collection_id: str) -> Collection: collection as a `Collection` object. If the collection is not found, a `NotFoundError` is raised. """ try: - collection = await self.client.get(index=COLLECTIONS_INDEX, id=collection_id) + collection = await self.client.get( + index=COLLECTIONS_INDEX, id=collection_id + ) except exceptions.NotFoundError: raise NotFoundError(f"Collection {collection_id} not found") return collection["_source"] - async def update_collection(self, collection_id: str, collection: Collection, refresh: bool = False): + async def update_collection( + self, collection_id: str, collection: Collection, refresh: bool = False + ): """Update a collection from the database. Args: @@ -1214,7 +1258,9 @@ async def json_patch_collection( if new_collection_id: collection["id"] = new_collection_id collection["links"] = resolve_links([], base_url) - await self.update_collection(collection_id=collection_id, collection=collection, refresh=False) + await self.update_collection( + collection_id=collection_id, collection=collection, refresh=False + ) return collection @@ -1235,10 +1281,14 @@ async def delete_collection(self, collection_id: str, refresh: bool = False): function also calls `delete_item_index` to delete the index for the items in the collection. """ await self.find_collection(collection_id=collection_id) - await self.client.delete(index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh) + await self.client.delete( + index=COLLECTIONS_INDEX, id=collection_id, refresh=refresh + ) await delete_item_index(collection_id) - async def bulk_async(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: + async def bulk_async( + self, collection_id: str, processed_items: List[Item], refresh: bool = False + ) -> None: """Perform a bulk insert of items into the database asynchronously. Args: @@ -1260,7 +1310,9 @@ async def bulk_async(self, collection_id: str, processed_items: List[Item], refr raise_on_error=False, ) - def bulk_sync(self, collection_id: str, processed_items: List[Item], refresh: bool = False) -> None: + def bulk_sync( + self, collection_id: str, processed_items: List[Item], refresh: bool = False + ) -> None: """Perform a bulk insert of items into the database synchronously. Args: diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 67e8999f..87e0d1b4 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -43,7 +43,9 @@ async def test_update_collection( collection_data = load_test_data("test_collection.json") item_data = load_test_data("test_item.json") - await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) + await txn_client.create_collection( + api.Collection(**collection_data), request=MockRequest + ) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -52,7 +54,9 @@ async def test_update_collection( ) collection_data["keywords"].append("new keyword") - await txn_client.update_collection(collection_data["id"], api.Collection(**collection_data), request=MockRequest) + await txn_client.update_collection( + collection_data["id"], api.Collection(**collection_data), request=MockRequest + ) coll = await core_client.get_collection(collection_data["id"], request=MockRequest) assert "new keyword" in coll["keywords"] @@ -79,7 +83,9 @@ async def test_update_collection_id( item_data = load_test_data("test_item.json") new_collection_id = "new-test-collection" - await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) + await txn_client.create_collection( + api.Collection(**collection_data), request=MockRequest + ) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -191,10 +197,14 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): @pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): - resp = await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) - assert Item(**ctx.item).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) == Item( - **resp - ).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) + resp = await core_client.get_item( + ctx.item["id"], ctx.item["collection"], request=MockRequest + ) + assert Item(**ctx.item).model_dump( + exclude={"links": ..., "properties": {"created", "updated"}} + ) == Item(**resp).model_dump( + exclude={"links": ..., "properties": {"created", "updated"}} + ) @pytest.mark.asyncio @@ -221,7 +231,9 @@ async def test_update_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == "bar" @@ -237,7 +249,9 @@ async def test_merge_patch_item_add(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == "bar" assert updated_item["properties"]["hello"] == "world" @@ -254,7 +268,9 @@ async def test_merge_patch_item_remove(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert "foo" not in updated_item["properties"] assert "hello" not in updated_item["properties"] @@ -275,7 +291,9 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["bar"] == "foo" @@ -296,7 +314,9 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == 100 @@ -317,7 +337,9 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == 100 @@ -338,7 +360,9 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["bar"] == 100 assert "foo" not in updated_item["properties"] @@ -360,7 +384,9 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == updated_item["properties"]["bar"] @@ -382,7 +408,9 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert "foo" not in updated_item["properties"] assert "bar" not in updated_item["properties"] @@ -408,7 +436,9 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): @pytest.mark.asyncio -async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_replace_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -427,7 +457,9 @@ async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client @pytest.mark.asyncio -async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_remove_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -446,7 +478,9 @@ async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, @pytest.mark.asyncio -async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_move_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -465,7 +499,9 @@ async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, t @pytest.mark.asyncio -async def test_json_patch_item_copy_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_copy_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -505,7 +541,9 @@ async def test_update_geometry(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["geometry"]["coordinates"] == new_coordinates @@ -514,7 +552,9 @@ async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) with pytest.raises(NotFoundError): - await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) + await core_client.get_item( + ctx.item["id"], ctx.item["collection"], request=MockRequest + ) @pytest.mark.asyncio @@ -563,7 +603,9 @@ async def test_feature_collection_insert( async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] - await txn_client.create_collection(api.Collection(**ctx.collection), request=MockRequest) + await txn_client.create_collection( + api.Collection(**ctx.collection), request=MockRequest + ) landing_page = await core_client.landing_page(request=MockRequest(app=app)) for link in landing_page["links"]: From 173ef0ddba9812fd0535ee5d3126795070f5a0f8 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 27 Mar 2025 16:05:50 +0000 Subject: [PATCH 17/38] Remove duplicate test name. --- stac_fastapi/tests/clients/test_elasticsearch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 87e0d1b4..bf0bb647 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -392,7 +392,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): @pytest.mark.asyncio -async def test_json_patch_item_copy(ctx, core_client, txn_client): +async def test_json_patch_item_remove(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] From 93350c7ce50c2f30ae1cfe9171c58407e8ada2ba Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 27 Mar 2025 16:40:42 +0000 Subject: [PATCH 18/38] PatchOperation not dict for tests. --- .../core/extensions/aggregation.py | 100 ++++------------ .../tests/clients/test_elasticsearch.py | 113 ++++++------------ 2 files changed, 61 insertions(+), 152 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index 2cf880c9..85511125 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -36,14 +36,10 @@ @attr.s -class EsAggregationExtensionGetRequest( - AggregationExtensionGetRequest, FilterExtensionGetRequest -): +class EsAggregationExtensionGetRequest(AggregationExtensionGetRequest, FilterExtensionGetRequest): """Implementation specific query parameters for aggregation precision.""" - collection_id: Optional[ - Annotated[str, Path(description="Collection ID")] - ] = attr.ib(default=None) + collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = attr.ib(default=None) centroid_geohash_grid_frequency_precision: Optional[int] = attr.ib(default=None) centroid_geohex_grid_frequency_precision: Optional[int] = attr.ib(default=None) @@ -53,9 +49,7 @@ class EsAggregationExtensionGetRequest( datetime_frequency_interval: Optional[str] = attr.ib(default=None) -class EsAggregationExtensionPostRequest( - AggregationExtensionPostRequest, FilterExtensionPostRequest -): +class EsAggregationExtensionPostRequest(AggregationExtensionPostRequest, FilterExtensionPostRequest): """Implementation specific query parameters for aggregation precision.""" centroid_geohash_grid_frequency_precision: Optional[int] = None @@ -153,9 +147,7 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) if await self.database.check_collection_exists(collection_id) is None: collection = await self.database.find_collection(collection_id) - aggregations = collection.get( - "aggregations", self.DEFAULT_AGGREGATIONS.copy() - ) + aggregations = collection.get("aggregations", self.DEFAULT_AGGREGATIONS.copy()) else: raise IndexError(f"Collection {collection_id} does not exist") else: @@ -168,13 +160,9 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) aggregations = self.DEFAULT_AGGREGATIONS - return AggregationCollection( - type="AggregationCollection", aggregations=aggregations, links=links - ) + return AggregationCollection(type="AggregationCollection", aggregations=aggregations, links=links) - def extract_precision( - self, precision: Union[int, None], min_value: int, max_value: int - ) -> Optional[int]: + def extract_precision(self, precision: Union[int, None], min_value: int, max_value: int) -> Optional[int]: """Ensure that the aggregation precision value is withing the a valid range, otherwise return the minumium value.""" if precision is not None: if precision < min_value or precision > max_value: @@ -211,9 +199,7 @@ def extract_date_histogram_interval(self, value: Optional[str]) -> str: return self.DEFAULT_DATETIME_INTERVAL @staticmethod - def _return_date( - interval: Optional[Union[DateTimeType, str]] - ) -> Dict[str, Optional[str]]: + def _return_date(interval: Optional[Union[DateTimeType, str]]) -> Dict[str, Optional[str]]: """ Convert a date interval. @@ -241,9 +227,7 @@ def _return_date( if "/" in interval: parts = interval.split("/") result["gte"] = parts[0] if parts[0] != ".." else None - result["lte"] = ( - parts[1] if len(parts) > 1 and parts[1] != ".." else None - ) + result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None else: converted_time = interval if interval != ".." else None result["gte"] = result["lte"] = converted_time @@ -283,9 +267,7 @@ def frequency_agg(self, es_aggs, name, data_type): def metric_agg(self, es_aggs, name, data_type): """Format an aggregation for a metric aggregation.""" - value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get( - name, {} - ).get("value") + value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get(name, {}).get("value") # ES 7.x does not return datetimes with a 'value_as_string' field if "datetime" in name and isinstance(value, float): value = datetime_to_str(datetime.fromtimestamp(value / 1e3)) @@ -331,9 +313,7 @@ def format_datetime(dt): async def aggregate( self, aggregate_request: Optional[EsAggregationExtensionPostRequest] = None, - collection_id: Optional[ - Annotated[str, Path(description="Collection ID")] - ] = None, + collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = None, collections: Optional[List[str]] = [], datetime: Optional[DateTimeType] = None, intersects: Optional[str] = None, @@ -389,10 +369,8 @@ async def aggregate( collection_id = path.split("/")[2] filter_lang = "cql2-json" - if aggregate_request.filter: - aggregate_request.filter = self.get_filter( - aggregate_request.filter, filter_lang - ) + if aggregate_request.filter_expr: + aggregate_request.filter_expr = self.get_filter(aggregate_request.filter_expr, filter_lang) if collection_id: if aggregate_request.collections: @@ -403,25 +381,18 @@ async def aggregate( else: aggregate_request.collections = [collection_id] - if ( - aggregate_request.aggregations is None - or aggregate_request.aggregations == [] - ): + if aggregate_request.aggregations is None or aggregate_request.aggregations == []: raise HTTPException( status_code=400, detail="No 'aggregations' found. Use '/aggregations' to return available aggregations", ) if aggregate_request.ids: - search = self.database.apply_ids_filter( - search=search, item_ids=aggregate_request.ids - ) + search = self.database.apply_ids_filter(search=search, item_ids=aggregate_request.ids) if aggregate_request.datetime: datetime_search = self._return_date(aggregate_request.datetime) - search = self.database.apply_datetime_filter( - search=search, datetime_search=datetime_search - ) + search = self.database.apply_datetime_filter(search=search, datetime_search=datetime_search) if aggregate_request.bbox: bbox = aggregate_request.bbox @@ -431,22 +402,14 @@ async def aggregate( search = self.database.apply_bbox_filter(search=search, bbox=bbox) if aggregate_request.intersects: - search = self.database.apply_intersects_filter( - search=search, intersects=aggregate_request.intersects - ) + search = self.database.apply_intersects_filter(search=search, intersects=aggregate_request.intersects) if aggregate_request.collections: - search = self.database.apply_collections_filter( - search=search, collection_ids=aggregate_request.collections - ) + search = self.database.apply_collections_filter(search=search, collection_ids=aggregate_request.collections) # validate that aggregations are supported for all collections for collection_id in aggregate_request.collections: - aggs = await self.get_aggregations( - collection_id=collection_id, request=request - ) - supported_aggregations = ( - aggs["aggregations"] + self.DEFAULT_AGGREGATIONS - ) + aggs = await self.get_aggregations(collection_id=collection_id, request=request) + supported_aggregations = aggs["aggregations"] + self.DEFAULT_AGGREGATIONS for agg_name in aggregate_request.aggregations: if agg_name not in set([x["name"] for x in supported_aggregations]): @@ -467,13 +430,9 @@ async def aggregate( if aggregate_request.filter: try: - search = self.database.apply_cql2_filter( - search, aggregate_request.filter - ) + search = self.database.apply_cql2_filter(search, aggregate_request.filter) except Exception as e: - raise HTTPException( - status_code=400, detail=f"Error with cql2 filter: {e}" - ) + raise HTTPException(status_code=400, detail=f"Error with cql2 filter: {e}") centroid_geohash_grid_precision = self.extract_precision( aggregate_request.centroid_geohash_grid_frequency_precision, @@ -528,20 +487,13 @@ async def aggregate( if db_response: result_aggs = db_response.get("aggregations", {}) for agg in { - frozenset(item.items()): item - for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS + frozenset(item.items()): item for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS }.values(): if agg["name"] in aggregate_request.aggregations: if agg["name"].endswith("_frequency"): - aggs.append( - self.frequency_agg( - result_aggs, agg["name"], agg["data_type"] - ) - ) + aggs.append(self.frequency_agg(result_aggs, agg["name"], agg["data_type"])) else: - aggs.append( - self.metric_agg(result_aggs, agg["name"], agg["data_type"]) - ) + aggs.append(self.metric_agg(result_aggs, agg["name"], agg["data_type"])) links = [ {"rel": "root", "type": "application/json", "href": base_url}, ] @@ -570,8 +522,6 @@ async def aggregate( "href": urljoin(base_url, "aggregate"), } ) - results = AggregationCollection( - type="AggregationCollection", aggregations=aggs, links=links - ) + results = AggregationCollection(type="AggregationCollection", aggregations=aggs, links=links) return results diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index bf0bb647..d9b3a009 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -7,6 +7,7 @@ from stac_fastapi.extensions.third_party.bulk_transactions import Items from stac_fastapi.types.errors import ConflictError, NotFoundError +from stac_fastapi.types.stac import PatchOperation from ..conftest import MockRequest, create_item @@ -43,9 +44,7 @@ async def test_update_collection( collection_data = load_test_data("test_collection.json") item_data = load_test_data("test_item.json") - await txn_client.create_collection( - api.Collection(**collection_data), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -54,9 +53,7 @@ async def test_update_collection( ) collection_data["keywords"].append("new keyword") - await txn_client.update_collection( - collection_data["id"], api.Collection(**collection_data), request=MockRequest - ) + await txn_client.update_collection(collection_data["id"], api.Collection(**collection_data), request=MockRequest) coll = await core_client.get_collection(collection_data["id"], request=MockRequest) assert "new keyword" in coll["keywords"] @@ -83,9 +80,7 @@ async def test_update_collection_id( item_data = load_test_data("test_item.json") new_collection_id = "new-test-collection" - await txn_client.create_collection( - api.Collection(**collection_data), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -197,14 +192,10 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): @pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): - resp = await core_client.get_item( - ctx.item["id"], ctx.item["collection"], request=MockRequest - ) - assert Item(**ctx.item).model_dump( - exclude={"links": ..., "properties": {"created", "updated"}} - ) == Item(**resp).model_dump( - exclude={"links": ..., "properties": {"created", "updated"}} - ) + resp = await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) + assert Item(**ctx.item).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) == Item( + **resp + ).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) @pytest.mark.asyncio @@ -231,9 +222,7 @@ async def test_update_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == "bar" @@ -249,9 +238,7 @@ async def test_merge_patch_item_add(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == "bar" assert updated_item["properties"]["hello"] == "world" @@ -268,9 +255,7 @@ async def test_merge_patch_item_remove(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert "foo" not in updated_item["properties"] assert "hello" not in updated_item["properties"] @@ -281,7 +266,7 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "add", "path": "properties.foo", "value": "bar"}, + PatchOperation(**{"op": "add", "path": "properties.foo", "value": "bar"}), ] await txn_client.json_patch_item( @@ -291,9 +276,7 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["bar"] == "foo" @@ -304,7 +287,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "replace", "path": "properties.foo", "value": 100}, + PatchOperation(**{"op": "replace", "path": "properties.foo", "value": 100}), ] await txn_client.json_patch_item( @@ -314,9 +297,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == 100 @@ -327,7 +308,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "test", "path": "properties.foo", "value": 100}, + PatchOperation(**{"op": "test", "path": "properties.foo", "value": 100}), ] await txn_client.json_patch_item( @@ -337,9 +318,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == 100 @@ -350,7 +329,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "move", "path": "properties.bar", "from": "properties.foo"}, + PatchOperation(**{"op": "move", "path": "properties.bar", "from": "properties.foo"}), ] await txn_client.json_patch_item( @@ -360,9 +339,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["bar"] == 100 assert "foo" not in updated_item["properties"] @@ -374,7 +351,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "copy", "path": "properties.foo", "from": "properties.bar"}, + PatchOperation(**{"op": "copy", "path": "properties.foo", "from": "properties.bar"}), ] await txn_client.json_patch_item( @@ -384,9 +361,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["properties"]["foo"] == updated_item["properties"]["bar"] @@ -397,8 +372,8 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "remove", "path": "properties.foo"}, - {"op": "remove", "path": "properties.bar"}, + PatchOperation(**{"op": "remove", "path": "properties.foo"}), + PatchOperation(**{"op": "remove", "path": "properties.bar"}), ] await txn_client.json_patch_item( @@ -408,9 +383,7 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert "foo" not in updated_item["properties"] assert "bar" not in updated_item["properties"] @@ -422,7 +395,7 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "test", "path": "properties.platform", "value": "landsat-9"}, + PatchOperation(**{"op": "test", "path": "properties.platform", "value": "landsat-9"}), ] with pytest.raises(ConflictError): @@ -436,14 +409,12 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): @pytest.mark.asyncio -async def test_json_patch_item_replace_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "replace", "path": "properties.foo", "value": "landsat-9"}, + PatchOperation(**{"op": "replace", "path": "properties.foo", "value": "landsat-9"}), ] with pytest.raises(ConflictError): @@ -457,14 +428,12 @@ async def test_json_patch_item_replace_property_does_not_exists( @pytest.mark.asyncio -async def test_json_patch_item_remove_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "remove", "path": "properties.foo"}, + PatchOperation(**{"op": "remove", "path": "properties.foo"}), ] with pytest.raises(ConflictError): @@ -478,14 +447,12 @@ async def test_json_patch_item_remove_property_does_not_exists( @pytest.mark.asyncio -async def test_json_patch_item_move_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "move", "path": "properties.bar", "from": "properties.foo"}, + PatchOperation(**{"op": "move", "path": "properties.bar", "from": "properties.foo"}), ] with pytest.raises(ConflictError): @@ -499,14 +466,12 @@ async def test_json_patch_item_move_property_does_not_exists( @pytest.mark.asyncio -async def test_json_patch_item_copy_property_does_not_exists( - ctx, core_client, txn_client -): +async def test_json_patch_item_copy_property_does_not_exists(ctx, core_client, txn_client): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - {"op": "copy", "path": "properties.bar", "from": "properties.foo"}, + PatchOperation(**{"op": "copy", "path": "properties.bar", "from": "properties.foo"}), ] with pytest.raises(ConflictError): @@ -541,9 +506,7 @@ async def test_update_geometry(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item( - item_id, collection_id, request=MockRequest - ) + updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) assert updated_item["geometry"]["coordinates"] == new_coordinates @@ -552,9 +515,7 @@ async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) with pytest.raises(NotFoundError): - await core_client.get_item( - ctx.item["id"], ctx.item["collection"], request=MockRequest - ) + await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) @pytest.mark.asyncio @@ -603,9 +564,7 @@ async def test_feature_collection_insert( async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] - await txn_client.create_collection( - api.Collection(**ctx.collection), request=MockRequest - ) + await txn_client.create_collection(api.Collection(**ctx.collection), request=MockRequest) landing_page = await core_client.landing_page(request=MockRequest(app=app)) for link in landing_page["links"]: From bf6f96a599777076db47688f43b198c1d9f9f44a Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Thu, 27 Mar 2025 16:42:25 +0000 Subject: [PATCH 19/38] pre-commit. --- .../core/extensions/aggregation.py | 98 +++++++++++---- .../tests/clients/test_elasticsearch.py | 112 +++++++++++++----- 2 files changed, 157 insertions(+), 53 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index 85511125..71cb3ae8 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -36,10 +36,14 @@ @attr.s -class EsAggregationExtensionGetRequest(AggregationExtensionGetRequest, FilterExtensionGetRequest): +class EsAggregationExtensionGetRequest( + AggregationExtensionGetRequest, FilterExtensionGetRequest +): """Implementation specific query parameters for aggregation precision.""" - collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = attr.ib(default=None) + collection_id: Optional[ + Annotated[str, Path(description="Collection ID")] + ] = attr.ib(default=None) centroid_geohash_grid_frequency_precision: Optional[int] = attr.ib(default=None) centroid_geohex_grid_frequency_precision: Optional[int] = attr.ib(default=None) @@ -49,7 +53,9 @@ class EsAggregationExtensionGetRequest(AggregationExtensionGetRequest, FilterExt datetime_frequency_interval: Optional[str] = attr.ib(default=None) -class EsAggregationExtensionPostRequest(AggregationExtensionPostRequest, FilterExtensionPostRequest): +class EsAggregationExtensionPostRequest( + AggregationExtensionPostRequest, FilterExtensionPostRequest +): """Implementation specific query parameters for aggregation precision.""" centroid_geohash_grid_frequency_precision: Optional[int] = None @@ -147,7 +153,9 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) if await self.database.check_collection_exists(collection_id) is None: collection = await self.database.find_collection(collection_id) - aggregations = collection.get("aggregations", self.DEFAULT_AGGREGATIONS.copy()) + aggregations = collection.get( + "aggregations", self.DEFAULT_AGGREGATIONS.copy() + ) else: raise IndexError(f"Collection {collection_id} does not exist") else: @@ -160,9 +168,13 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) aggregations = self.DEFAULT_AGGREGATIONS - return AggregationCollection(type="AggregationCollection", aggregations=aggregations, links=links) + return AggregationCollection( + type="AggregationCollection", aggregations=aggregations, links=links + ) - def extract_precision(self, precision: Union[int, None], min_value: int, max_value: int) -> Optional[int]: + def extract_precision( + self, precision: Union[int, None], min_value: int, max_value: int + ) -> Optional[int]: """Ensure that the aggregation precision value is withing the a valid range, otherwise return the minumium value.""" if precision is not None: if precision < min_value or precision > max_value: @@ -199,7 +211,9 @@ def extract_date_histogram_interval(self, value: Optional[str]) -> str: return self.DEFAULT_DATETIME_INTERVAL @staticmethod - def _return_date(interval: Optional[Union[DateTimeType, str]]) -> Dict[str, Optional[str]]: + def _return_date( + interval: Optional[Union[DateTimeType, str]] + ) -> Dict[str, Optional[str]]: """ Convert a date interval. @@ -227,7 +241,9 @@ def _return_date(interval: Optional[Union[DateTimeType, str]]) -> Dict[str, Opti if "/" in interval: parts = interval.split("/") result["gte"] = parts[0] if parts[0] != ".." else None - result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None + result["lte"] = ( + parts[1] if len(parts) > 1 and parts[1] != ".." else None + ) else: converted_time = interval if interval != ".." else None result["gte"] = result["lte"] = converted_time @@ -267,7 +283,9 @@ def frequency_agg(self, es_aggs, name, data_type): def metric_agg(self, es_aggs, name, data_type): """Format an aggregation for a metric aggregation.""" - value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get(name, {}).get("value") + value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get( + name, {} + ).get("value") # ES 7.x does not return datetimes with a 'value_as_string' field if "datetime" in name and isinstance(value, float): value = datetime_to_str(datetime.fromtimestamp(value / 1e3)) @@ -313,7 +331,9 @@ def format_datetime(dt): async def aggregate( self, aggregate_request: Optional[EsAggregationExtensionPostRequest] = None, - collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = None, + collection_id: Optional[ + Annotated[str, Path(description="Collection ID")] + ] = None, collections: Optional[List[str]] = [], datetime: Optional[DateTimeType] = None, intersects: Optional[str] = None, @@ -370,7 +390,9 @@ async def aggregate( filter_lang = "cql2-json" if aggregate_request.filter_expr: - aggregate_request.filter_expr = self.get_filter(aggregate_request.filter_expr, filter_lang) + aggregate_request.filter_expr = self.get_filter( + aggregate_request.filter_expr, filter_lang + ) if collection_id: if aggregate_request.collections: @@ -381,18 +403,25 @@ async def aggregate( else: aggregate_request.collections = [collection_id] - if aggregate_request.aggregations is None or aggregate_request.aggregations == []: + if ( + aggregate_request.aggregations is None + or aggregate_request.aggregations == [] + ): raise HTTPException( status_code=400, detail="No 'aggregations' found. Use '/aggregations' to return available aggregations", ) if aggregate_request.ids: - search = self.database.apply_ids_filter(search=search, item_ids=aggregate_request.ids) + search = self.database.apply_ids_filter( + search=search, item_ids=aggregate_request.ids + ) if aggregate_request.datetime: datetime_search = self._return_date(aggregate_request.datetime) - search = self.database.apply_datetime_filter(search=search, datetime_search=datetime_search) + search = self.database.apply_datetime_filter( + search=search, datetime_search=datetime_search + ) if aggregate_request.bbox: bbox = aggregate_request.bbox @@ -402,14 +431,22 @@ async def aggregate( search = self.database.apply_bbox_filter(search=search, bbox=bbox) if aggregate_request.intersects: - search = self.database.apply_intersects_filter(search=search, intersects=aggregate_request.intersects) + search = self.database.apply_intersects_filter( + search=search, intersects=aggregate_request.intersects + ) if aggregate_request.collections: - search = self.database.apply_collections_filter(search=search, collection_ids=aggregate_request.collections) + search = self.database.apply_collections_filter( + search=search, collection_ids=aggregate_request.collections + ) # validate that aggregations are supported for all collections for collection_id in aggregate_request.collections: - aggs = await self.get_aggregations(collection_id=collection_id, request=request) - supported_aggregations = aggs["aggregations"] + self.DEFAULT_AGGREGATIONS + aggs = await self.get_aggregations( + collection_id=collection_id, request=request + ) + supported_aggregations = ( + aggs["aggregations"] + self.DEFAULT_AGGREGATIONS + ) for agg_name in aggregate_request.aggregations: if agg_name not in set([x["name"] for x in supported_aggregations]): @@ -430,9 +467,13 @@ async def aggregate( if aggregate_request.filter: try: - search = self.database.apply_cql2_filter(search, aggregate_request.filter) + search = self.database.apply_cql2_filter( + search, aggregate_request.filter + ) except Exception as e: - raise HTTPException(status_code=400, detail=f"Error with cql2 filter: {e}") + raise HTTPException( + status_code=400, detail=f"Error with cql2 filter: {e}" + ) centroid_geohash_grid_precision = self.extract_precision( aggregate_request.centroid_geohash_grid_frequency_precision, @@ -487,13 +528,20 @@ async def aggregate( if db_response: result_aggs = db_response.get("aggregations", {}) for agg in { - frozenset(item.items()): item for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS + frozenset(item.items()): item + for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS }.values(): if agg["name"] in aggregate_request.aggregations: if agg["name"].endswith("_frequency"): - aggs.append(self.frequency_agg(result_aggs, agg["name"], agg["data_type"])) + aggs.append( + self.frequency_agg( + result_aggs, agg["name"], agg["data_type"] + ) + ) else: - aggs.append(self.metric_agg(result_aggs, agg["name"], agg["data_type"])) + aggs.append( + self.metric_agg(result_aggs, agg["name"], agg["data_type"]) + ) links = [ {"rel": "root", "type": "application/json", "href": base_url}, ] @@ -522,6 +570,8 @@ async def aggregate( "href": urljoin(base_url, "aggregate"), } ) - results = AggregationCollection(type="AggregationCollection", aggregations=aggs, links=links) + results = AggregationCollection( + type="AggregationCollection", aggregations=aggs, links=links + ) return results diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index d9b3a009..f9c35a3a 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -44,7 +44,9 @@ async def test_update_collection( collection_data = load_test_data("test_collection.json") item_data = load_test_data("test_item.json") - await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) + await txn_client.create_collection( + api.Collection(**collection_data), request=MockRequest + ) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -53,7 +55,9 @@ async def test_update_collection( ) collection_data["keywords"].append("new keyword") - await txn_client.update_collection(collection_data["id"], api.Collection(**collection_data), request=MockRequest) + await txn_client.update_collection( + collection_data["id"], api.Collection(**collection_data), request=MockRequest + ) coll = await core_client.get_collection(collection_data["id"], request=MockRequest) assert "new keyword" in coll["keywords"] @@ -80,7 +84,9 @@ async def test_update_collection_id( item_data = load_test_data("test_item.json") new_collection_id = "new-test-collection" - await txn_client.create_collection(api.Collection(**collection_data), request=MockRequest) + await txn_client.create_collection( + api.Collection(**collection_data), request=MockRequest + ) await txn_client.create_item( collection_id=collection_data["id"], item=api.Item(**item_data), @@ -192,10 +198,14 @@ async def test_get_collection_items(app_client, ctx, core_client, txn_client): @pytest.mark.asyncio async def test_create_item(ctx, core_client, txn_client): - resp = await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) - assert Item(**ctx.item).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) == Item( - **resp - ).model_dump(exclude={"links": ..., "properties": {"created", "updated"}}) + resp = await core_client.get_item( + ctx.item["id"], ctx.item["collection"], request=MockRequest + ) + assert Item(**ctx.item).model_dump( + exclude={"links": ..., "properties": {"created", "updated"}} + ) == Item(**resp).model_dump( + exclude={"links": ..., "properties": {"created", "updated"}} + ) @pytest.mark.asyncio @@ -222,7 +232,9 @@ async def test_update_item(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == "bar" @@ -238,7 +250,9 @@ async def test_merge_patch_item_add(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == "bar" assert updated_item["properties"]["hello"] == "world" @@ -255,7 +269,9 @@ async def test_merge_patch_item_remove(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert "foo" not in updated_item["properties"] assert "hello" not in updated_item["properties"] @@ -276,7 +292,9 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["bar"] == "foo" @@ -297,7 +315,9 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == 100 @@ -318,7 +338,9 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == 100 @@ -329,7 +351,9 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "move", "path": "properties.bar", "from": "properties.foo"}), + PatchOperation( + **{"op": "move", "path": "properties.bar", "from": "properties.foo"} + ), ] await txn_client.json_patch_item( @@ -339,7 +363,9 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["bar"] == 100 assert "foo" not in updated_item["properties"] @@ -351,7 +377,9 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "copy", "path": "properties.foo", "from": "properties.bar"}), + PatchOperation( + **{"op": "copy", "path": "properties.foo", "from": "properties.bar"} + ), ] await txn_client.json_patch_item( @@ -361,7 +389,9 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["properties"]["foo"] == updated_item["properties"]["bar"] @@ -383,7 +413,9 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert "foo" not in updated_item["properties"] assert "bar" not in updated_item["properties"] @@ -395,7 +427,9 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "test", "path": "properties.platform", "value": "landsat-9"}), + PatchOperation( + **{"op": "test", "path": "properties.platform", "value": "landsat-9"} + ), ] with pytest.raises(ConflictError): @@ -409,12 +443,16 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): @pytest.mark.asyncio -async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_replace_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "replace", "path": "properties.foo", "value": "landsat-9"}), + PatchOperation( + **{"op": "replace", "path": "properties.foo", "value": "landsat-9"} + ), ] with pytest.raises(ConflictError): @@ -428,7 +466,9 @@ async def test_json_patch_item_replace_property_does_not_exists(ctx, core_client @pytest.mark.asyncio -async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_remove_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] @@ -447,12 +487,16 @@ async def test_json_patch_item_remove_property_does_not_exists(ctx, core_client, @pytest.mark.asyncio -async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_move_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "move", "path": "properties.bar", "from": "properties.foo"}), + PatchOperation( + **{"op": "move", "path": "properties.bar", "from": "properties.foo"} + ), ] with pytest.raises(ConflictError): @@ -466,12 +510,16 @@ async def test_json_patch_item_move_property_does_not_exists(ctx, core_client, t @pytest.mark.asyncio -async def test_json_patch_item_copy_property_does_not_exists(ctx, core_client, txn_client): +async def test_json_patch_item_copy_property_does_not_exists( + ctx, core_client, txn_client +): item = ctx.item collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "copy", "path": "properties.bar", "from": "properties.foo"}), + PatchOperation( + **{"op": "copy", "path": "properties.bar", "from": "properties.foo"} + ), ] with pytest.raises(ConflictError): @@ -506,7 +554,9 @@ async def test_update_geometry(ctx, core_client, txn_client): request=MockRequest, ) - updated_item = await core_client.get_item(item_id, collection_id, request=MockRequest) + updated_item = await core_client.get_item( + item_id, collection_id, request=MockRequest + ) assert updated_item["geometry"]["coordinates"] == new_coordinates @@ -515,7 +565,9 @@ async def test_delete_item(ctx, core_client, txn_client): await txn_client.delete_item(ctx.item["id"], ctx.item["collection"]) with pytest.raises(NotFoundError): - await core_client.get_item(ctx.item["id"], ctx.item["collection"], request=MockRequest) + await core_client.get_item( + ctx.item["id"], ctx.item["collection"], request=MockRequest + ) @pytest.mark.asyncio @@ -564,7 +616,9 @@ async def test_feature_collection_insert( async def test_landing_page_no_collection_title(ctx, core_client, txn_client, app): ctx.collection["id"] = "new_id" del ctx.collection["title"] - await txn_client.create_collection(api.Collection(**ctx.collection), request=MockRequest) + await txn_client.create_collection( + api.Collection(**ctx.collection), request=MockRequest + ) landing_page = await core_client.landing_page(request=MockRequest(app=app)) for link in landing_page["links"]: From 29f6e2e7aac055e4afd5e8cc981b29dbcb11e683 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 09:55:56 +0100 Subject: [PATCH 20/38] filter_expr not filter for aggregation request. --- .../core/extensions/aggregation.py | 100 +++++------------- 1 file changed, 25 insertions(+), 75 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index 71cb3ae8..dbcbfbfb 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -36,14 +36,10 @@ @attr.s -class EsAggregationExtensionGetRequest( - AggregationExtensionGetRequest, FilterExtensionGetRequest -): +class EsAggregationExtensionGetRequest(AggregationExtensionGetRequest, FilterExtensionGetRequest): """Implementation specific query parameters for aggregation precision.""" - collection_id: Optional[ - Annotated[str, Path(description="Collection ID")] - ] = attr.ib(default=None) + collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = attr.ib(default=None) centroid_geohash_grid_frequency_precision: Optional[int] = attr.ib(default=None) centroid_geohex_grid_frequency_precision: Optional[int] = attr.ib(default=None) @@ -53,9 +49,7 @@ class EsAggregationExtensionGetRequest( datetime_frequency_interval: Optional[str] = attr.ib(default=None) -class EsAggregationExtensionPostRequest( - AggregationExtensionPostRequest, FilterExtensionPostRequest -): +class EsAggregationExtensionPostRequest(AggregationExtensionPostRequest, FilterExtensionPostRequest): """Implementation specific query parameters for aggregation precision.""" centroid_geohash_grid_frequency_precision: Optional[int] = None @@ -153,9 +147,7 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) if await self.database.check_collection_exists(collection_id) is None: collection = await self.database.find_collection(collection_id) - aggregations = collection.get( - "aggregations", self.DEFAULT_AGGREGATIONS.copy() - ) + aggregations = collection.get("aggregations", self.DEFAULT_AGGREGATIONS.copy()) else: raise IndexError(f"Collection {collection_id} does not exist") else: @@ -168,13 +160,9 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) aggregations = self.DEFAULT_AGGREGATIONS - return AggregationCollection( - type="AggregationCollection", aggregations=aggregations, links=links - ) + return AggregationCollection(type="AggregationCollection", aggregations=aggregations, links=links) - def extract_precision( - self, precision: Union[int, None], min_value: int, max_value: int - ) -> Optional[int]: + def extract_precision(self, precision: Union[int, None], min_value: int, max_value: int) -> Optional[int]: """Ensure that the aggregation precision value is withing the a valid range, otherwise return the minumium value.""" if precision is not None: if precision < min_value or precision > max_value: @@ -211,9 +199,7 @@ def extract_date_histogram_interval(self, value: Optional[str]) -> str: return self.DEFAULT_DATETIME_INTERVAL @staticmethod - def _return_date( - interval: Optional[Union[DateTimeType, str]] - ) -> Dict[str, Optional[str]]: + def _return_date(interval: Optional[Union[DateTimeType, str]]) -> Dict[str, Optional[str]]: """ Convert a date interval. @@ -241,9 +227,7 @@ def _return_date( if "/" in interval: parts = interval.split("/") result["gte"] = parts[0] if parts[0] != ".." else None - result["lte"] = ( - parts[1] if len(parts) > 1 and parts[1] != ".." else None - ) + result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None else: converted_time = interval if interval != ".." else None result["gte"] = result["lte"] = converted_time @@ -283,9 +267,7 @@ def frequency_agg(self, es_aggs, name, data_type): def metric_agg(self, es_aggs, name, data_type): """Format an aggregation for a metric aggregation.""" - value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get( - name, {} - ).get("value") + value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get(name, {}).get("value") # ES 7.x does not return datetimes with a 'value_as_string' field if "datetime" in name and isinstance(value, float): value = datetime_to_str(datetime.fromtimestamp(value / 1e3)) @@ -331,9 +313,7 @@ def format_datetime(dt): async def aggregate( self, aggregate_request: Optional[EsAggregationExtensionPostRequest] = None, - collection_id: Optional[ - Annotated[str, Path(description="Collection ID")] - ] = None, + collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = None, collections: Optional[List[str]] = [], datetime: Optional[DateTimeType] = None, intersects: Optional[str] = None, @@ -390,9 +370,7 @@ async def aggregate( filter_lang = "cql2-json" if aggregate_request.filter_expr: - aggregate_request.filter_expr = self.get_filter( - aggregate_request.filter_expr, filter_lang - ) + aggregate_request.filter_expr = self.get_filter(aggregate_request.filter_expr, filter_lang) if collection_id: if aggregate_request.collections: @@ -403,25 +381,18 @@ async def aggregate( else: aggregate_request.collections = [collection_id] - if ( - aggregate_request.aggregations is None - or aggregate_request.aggregations == [] - ): + if aggregate_request.aggregations is None or aggregate_request.aggregations == []: raise HTTPException( status_code=400, detail="No 'aggregations' found. Use '/aggregations' to return available aggregations", ) if aggregate_request.ids: - search = self.database.apply_ids_filter( - search=search, item_ids=aggregate_request.ids - ) + search = self.database.apply_ids_filter(search=search, item_ids=aggregate_request.ids) if aggregate_request.datetime: datetime_search = self._return_date(aggregate_request.datetime) - search = self.database.apply_datetime_filter( - search=search, datetime_search=datetime_search - ) + search = self.database.apply_datetime_filter(search=search, datetime_search=datetime_search) if aggregate_request.bbox: bbox = aggregate_request.bbox @@ -431,22 +402,14 @@ async def aggregate( search = self.database.apply_bbox_filter(search=search, bbox=bbox) if aggregate_request.intersects: - search = self.database.apply_intersects_filter( - search=search, intersects=aggregate_request.intersects - ) + search = self.database.apply_intersects_filter(search=search, intersects=aggregate_request.intersects) if aggregate_request.collections: - search = self.database.apply_collections_filter( - search=search, collection_ids=aggregate_request.collections - ) + search = self.database.apply_collections_filter(search=search, collection_ids=aggregate_request.collections) # validate that aggregations are supported for all collections for collection_id in aggregate_request.collections: - aggs = await self.get_aggregations( - collection_id=collection_id, request=request - ) - supported_aggregations = ( - aggs["aggregations"] + self.DEFAULT_AGGREGATIONS - ) + aggs = await self.get_aggregations(collection_id=collection_id, request=request) + supported_aggregations = aggs["aggregations"] + self.DEFAULT_AGGREGATIONS for agg_name in aggregate_request.aggregations: if agg_name not in set([x["name"] for x in supported_aggregations]): @@ -465,15 +428,11 @@ async def aggregate( detail=f"Aggregation {agg_name} not supported at catalog level", ) - if aggregate_request.filter: + if aggregate_request.filter_expr: try: - search = self.database.apply_cql2_filter( - search, aggregate_request.filter - ) + search = self.database.apply_cql2_filter(search, aggregate_request.filter_expr) except Exception as e: - raise HTTPException( - status_code=400, detail=f"Error with cql2 filter: {e}" - ) + raise HTTPException(status_code=400, detail=f"Error with cql2 filter: {e}") centroid_geohash_grid_precision = self.extract_precision( aggregate_request.centroid_geohash_grid_frequency_precision, @@ -528,20 +487,13 @@ async def aggregate( if db_response: result_aggs = db_response.get("aggregations", {}) for agg in { - frozenset(item.items()): item - for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS + frozenset(item.items()): item for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS }.values(): if agg["name"] in aggregate_request.aggregations: if agg["name"].endswith("_frequency"): - aggs.append( - self.frequency_agg( - result_aggs, agg["name"], agg["data_type"] - ) - ) + aggs.append(self.frequency_agg(result_aggs, agg["name"], agg["data_type"])) else: - aggs.append( - self.metric_agg(result_aggs, agg["name"], agg["data_type"]) - ) + aggs.append(self.metric_agg(result_aggs, agg["name"], agg["data_type"])) links = [ {"rel": "root", "type": "application/json", "href": base_url}, ] @@ -570,8 +522,6 @@ async def aggregate( "href": urljoin(base_url, "aggregate"), } ) - results = AggregationCollection( - type="AggregationCollection", aggregations=aggs, links=links - ) + results = AggregationCollection(type="AggregationCollection", aggregations=aggs, links=links) return results From 3d5b168d0b39a0d1bf5e79abeb9de5be927e116d Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 09:59:14 +0100 Subject: [PATCH 21/38] pre-commit. --- .../core/extensions/aggregation.py | 98 ++++++++++++++----- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index dbcbfbfb..a3698caf 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -36,10 +36,14 @@ @attr.s -class EsAggregationExtensionGetRequest(AggregationExtensionGetRequest, FilterExtensionGetRequest): +class EsAggregationExtensionGetRequest( + AggregationExtensionGetRequest, FilterExtensionGetRequest +): """Implementation specific query parameters for aggregation precision.""" - collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = attr.ib(default=None) + collection_id: Optional[ + Annotated[str, Path(description="Collection ID")] + ] = attr.ib(default=None) centroid_geohash_grid_frequency_precision: Optional[int] = attr.ib(default=None) centroid_geohex_grid_frequency_precision: Optional[int] = attr.ib(default=None) @@ -49,7 +53,9 @@ class EsAggregationExtensionGetRequest(AggregationExtensionGetRequest, FilterExt datetime_frequency_interval: Optional[str] = attr.ib(default=None) -class EsAggregationExtensionPostRequest(AggregationExtensionPostRequest, FilterExtensionPostRequest): +class EsAggregationExtensionPostRequest( + AggregationExtensionPostRequest, FilterExtensionPostRequest +): """Implementation specific query parameters for aggregation precision.""" centroid_geohash_grid_frequency_precision: Optional[int] = None @@ -147,7 +153,9 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) if await self.database.check_collection_exists(collection_id) is None: collection = await self.database.find_collection(collection_id) - aggregations = collection.get("aggregations", self.DEFAULT_AGGREGATIONS.copy()) + aggregations = collection.get( + "aggregations", self.DEFAULT_AGGREGATIONS.copy() + ) else: raise IndexError(f"Collection {collection_id} does not exist") else: @@ -160,9 +168,13 @@ async def get_aggregations(self, collection_id: Optional[str] = None, **kwargs): ) aggregations = self.DEFAULT_AGGREGATIONS - return AggregationCollection(type="AggregationCollection", aggregations=aggregations, links=links) + return AggregationCollection( + type="AggregationCollection", aggregations=aggregations, links=links + ) - def extract_precision(self, precision: Union[int, None], min_value: int, max_value: int) -> Optional[int]: + def extract_precision( + self, precision: Union[int, None], min_value: int, max_value: int + ) -> Optional[int]: """Ensure that the aggregation precision value is withing the a valid range, otherwise return the minumium value.""" if precision is not None: if precision < min_value or precision > max_value: @@ -199,7 +211,9 @@ def extract_date_histogram_interval(self, value: Optional[str]) -> str: return self.DEFAULT_DATETIME_INTERVAL @staticmethod - def _return_date(interval: Optional[Union[DateTimeType, str]]) -> Dict[str, Optional[str]]: + def _return_date( + interval: Optional[Union[DateTimeType, str]] + ) -> Dict[str, Optional[str]]: """ Convert a date interval. @@ -227,7 +241,9 @@ def _return_date(interval: Optional[Union[DateTimeType, str]]) -> Dict[str, Opti if "/" in interval: parts = interval.split("/") result["gte"] = parts[0] if parts[0] != ".." else None - result["lte"] = parts[1] if len(parts) > 1 and parts[1] != ".." else None + result["lte"] = ( + parts[1] if len(parts) > 1 and parts[1] != ".." else None + ) else: converted_time = interval if interval != ".." else None result["gte"] = result["lte"] = converted_time @@ -267,7 +283,9 @@ def frequency_agg(self, es_aggs, name, data_type): def metric_agg(self, es_aggs, name, data_type): """Format an aggregation for a metric aggregation.""" - value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get(name, {}).get("value") + value = es_aggs.get(name, {}).get("value_as_string") or es_aggs.get( + name, {} + ).get("value") # ES 7.x does not return datetimes with a 'value_as_string' field if "datetime" in name and isinstance(value, float): value = datetime_to_str(datetime.fromtimestamp(value / 1e3)) @@ -313,7 +331,9 @@ def format_datetime(dt): async def aggregate( self, aggregate_request: Optional[EsAggregationExtensionPostRequest] = None, - collection_id: Optional[Annotated[str, Path(description="Collection ID")]] = None, + collection_id: Optional[ + Annotated[str, Path(description="Collection ID")] + ] = None, collections: Optional[List[str]] = [], datetime: Optional[DateTimeType] = None, intersects: Optional[str] = None, @@ -370,7 +390,9 @@ async def aggregate( filter_lang = "cql2-json" if aggregate_request.filter_expr: - aggregate_request.filter_expr = self.get_filter(aggregate_request.filter_expr, filter_lang) + aggregate_request.filter_expr = self.get_filter( + aggregate_request.filter_expr, filter_lang + ) if collection_id: if aggregate_request.collections: @@ -381,18 +403,25 @@ async def aggregate( else: aggregate_request.collections = [collection_id] - if aggregate_request.aggregations is None or aggregate_request.aggregations == []: + if ( + aggregate_request.aggregations is None + or aggregate_request.aggregations == [] + ): raise HTTPException( status_code=400, detail="No 'aggregations' found. Use '/aggregations' to return available aggregations", ) if aggregate_request.ids: - search = self.database.apply_ids_filter(search=search, item_ids=aggregate_request.ids) + search = self.database.apply_ids_filter( + search=search, item_ids=aggregate_request.ids + ) if aggregate_request.datetime: datetime_search = self._return_date(aggregate_request.datetime) - search = self.database.apply_datetime_filter(search=search, datetime_search=datetime_search) + search = self.database.apply_datetime_filter( + search=search, datetime_search=datetime_search + ) if aggregate_request.bbox: bbox = aggregate_request.bbox @@ -402,14 +431,22 @@ async def aggregate( search = self.database.apply_bbox_filter(search=search, bbox=bbox) if aggregate_request.intersects: - search = self.database.apply_intersects_filter(search=search, intersects=aggregate_request.intersects) + search = self.database.apply_intersects_filter( + search=search, intersects=aggregate_request.intersects + ) if aggregate_request.collections: - search = self.database.apply_collections_filter(search=search, collection_ids=aggregate_request.collections) + search = self.database.apply_collections_filter( + search=search, collection_ids=aggregate_request.collections + ) # validate that aggregations are supported for all collections for collection_id in aggregate_request.collections: - aggs = await self.get_aggregations(collection_id=collection_id, request=request) - supported_aggregations = aggs["aggregations"] + self.DEFAULT_AGGREGATIONS + aggs = await self.get_aggregations( + collection_id=collection_id, request=request + ) + supported_aggregations = ( + aggs["aggregations"] + self.DEFAULT_AGGREGATIONS + ) for agg_name in aggregate_request.aggregations: if agg_name not in set([x["name"] for x in supported_aggregations]): @@ -430,9 +467,13 @@ async def aggregate( if aggregate_request.filter_expr: try: - search = self.database.apply_cql2_filter(search, aggregate_request.filter_expr) + search = self.database.apply_cql2_filter( + search, aggregate_request.filter_expr + ) except Exception as e: - raise HTTPException(status_code=400, detail=f"Error with cql2 filter: {e}") + raise HTTPException( + status_code=400, detail=f"Error with cql2 filter: {e}" + ) centroid_geohash_grid_precision = self.extract_precision( aggregate_request.centroid_geohash_grid_frequency_precision, @@ -487,13 +528,20 @@ async def aggregate( if db_response: result_aggs = db_response.get("aggregations", {}) for agg in { - frozenset(item.items()): item for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS + frozenset(item.items()): item + for item in supported_aggregations + self.GEO_POINT_AGGREGATIONS }.values(): if agg["name"] in aggregate_request.aggregations: if agg["name"].endswith("_frequency"): - aggs.append(self.frequency_agg(result_aggs, agg["name"], agg["data_type"])) + aggs.append( + self.frequency_agg( + result_aggs, agg["name"], agg["data_type"] + ) + ) else: - aggs.append(self.metric_agg(result_aggs, agg["name"], agg["data_type"])) + aggs.append( + self.metric_agg(result_aggs, agg["name"], agg["data_type"]) + ) links = [ {"rel": "root", "type": "application/json", "href": base_url}, ] @@ -522,6 +570,8 @@ async def aggregate( "href": urljoin(base_url, "aggregate"), } ) - results = AggregationCollection(type="AggregationCollection", aggregations=aggs, links=links) + results = AggregationCollection( + type="AggregationCollection", aggregations=aggs, links=links + ) return results From ca64ba39de5b6f4aa2fd9593e94383c7f578e293 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 12:03:42 +0100 Subject: [PATCH 22/38] Filter fix. --- stac_fastapi/core/stac_fastapi/core/core.py | 16 +++++++++------- .../stac_fastapi/core/extensions/aggregation.py | 6 +++--- stac_fastapi/core/stac_fastapi/core/utilities.py | 11 +++-------- .../stac_fastapi/elasticsearch/database_logic.py | 6 +++--- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 23a1a1e8..0aa06418 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -459,7 +459,7 @@ async def get_search( sortby: Optional[str] = None, q: Optional[List[str]] = None, intersects: Optional[str] = None, - filter: Optional[str] = None, + filter_expr: Optional[str] = None, filter_lang: Optional[str] = None, **kwargs, ) -> stac_types.ItemCollection: @@ -507,12 +507,13 @@ async def get_search( for sort in sortby ] - if filter: + if filter_expr: + print("GET FE", filter_expr) base_args["filter-lang"] = "cql2-json" base_args["filter"] = orjson.loads( - unquote_plus(filter) + unquote_plus(filter_expr) if filter_lang == "cql2-json" - else to_cql2(parse_cql2_text(filter)) + else to_cql2(parse_cql2_text(filter_expr)) ) if fields: @@ -594,10 +595,11 @@ async def post_search( ) # only cql2_json is supported here - if hasattr(search_request, "filter"): - cql2_filter = getattr(search_request, "filter", None) + if search_request.filter_expr: try: - search = self.database.apply_cql2_filter(search, cql2_filter) + search = self.database.apply_cql2_filter( + search, search_request.filter_expr + ) except Exception as e: raise HTTPException( status_code=400, detail=f"Error with cql2_json filter: {e}" diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index a3698caf..43bd543c 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -338,7 +338,7 @@ async def aggregate( datetime: Optional[DateTimeType] = None, intersects: Optional[str] = None, filter_lang: Optional[str] = None, - filter: Optional[str] = None, + filter_expr: Optional[str] = None, aggregations: Optional[str] = None, ids: Optional[List[str]] = None, bbox: Optional[BBox] = None, @@ -380,8 +380,8 @@ async def aggregate( if datetime: base_args["datetime"] = self._format_datetime_range(datetime) - if filter: - base_args["filter"] = self.get_filter(filter, filter_lang) + if filter_expr: + base_args["filter"] = self.get_filter(filter_expr, filter_lang) aggregate_request = EsAggregationExtensionPostRequest(**base_args) else: # Workaround for optional path param in POST requests diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index b3024b9c..9b928872 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -8,12 +8,7 @@ from typing import Any, Dict, List, Optional, Set, Union from stac_fastapi.core.models.patch import ElasticPath -from stac_fastapi.types.stac import ( - Item, - PatchAddReplaceTest, - PatchOperation, - PatchRemove, -) +from stac_fastapi.types.stac import Item, PatchOperation MAX_LIMIT = 10000 @@ -157,7 +152,7 @@ def merge_to_operations(data: Dict) -> List: for key, value in data.copy().items(): if value is None: - operations.append(PatchRemove(op="remove", path=key)) + operations.append(PatchOperation(op="remove", path=key)) elif isinstance(value, dict): nested_operations = merge_to_operations(value) @@ -167,7 +162,7 @@ def merge_to_operations(data: Dict) -> List: operations.append(nested_operation) else: - operations.append(PatchAddReplaceTest(op="add", path=key, value=value)) + operations.append(PatchOperation(op="add", path=key, value=value)) return operations diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 58f2ff5e..4547e319 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -619,7 +619,7 @@ def apply_free_text_filter(search: Search, free_text_queries: Optional[List[str] return search @staticmethod - def apply_cql2_filter(search: Search, _filter: Optional[Dict[str, Any]]): + def apply_cql2_filter(search: Search, filter_expr: Optional[Dict[str, Any]]): """ Apply a CQL2 filter to an Elasticsearch Search object. @@ -638,8 +638,8 @@ def apply_cql2_filter(search: Search, _filter: Optional[Dict[str, Any]]): Search: The modified Search object with the filter applied if a filter is provided, otherwise the original Search object. """ - if _filter is not None: - es_query = filter.to_es(_filter) + if filter_expr is not None: + es_query = filter.to_es(filter_expr) search = search.query(es_query) return search From b41257621f2623787599738bb88ad42b3046ac5d Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 14:00:40 +0100 Subject: [PATCH 23/38] remove PatchOperation in tests. --- stac_fastapi/core/stac_fastapi/core/core.py | 5 +-- .../core/extensions/aggregation.py | 4 +- .../tests/clients/test_elasticsearch.py | 44 +++++++++++-------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index 0aa06418..d3d8ef3d 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -493,11 +493,9 @@ async def get_search( "token": token, "query": orjson.loads(query) if query else query, "q": q, + "datetime": datetime, } - if datetime: - base_args["datetime"] = self._format_datetime_range(datetime) - if intersects: base_args["intersects"] = orjson.loads(unquote_plus(intersects)) @@ -508,7 +506,6 @@ async def get_search( ] if filter_expr: - print("GET FE", filter_expr) base_args["filter-lang"] = "cql2-json" base_args["filter"] = orjson.loads( unquote_plus(filter_expr) diff --git a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py index 43bd543c..87d5f8a1 100644 --- a/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py +++ b/stac_fastapi/core/stac_fastapi/core/extensions/aggregation.py @@ -369,6 +369,7 @@ async def aggregate( "geometry_geohash_grid_frequency_precision": geometry_geohash_grid_frequency_precision, "geometry_geotile_grid_frequency_precision": geometry_geotile_grid_frequency_precision, "datetime_frequency_interval": datetime_frequency_interval, + "datetime": datetime, } if collection_id: @@ -377,9 +378,6 @@ async def aggregate( if intersects: base_args["intersects"] = orjson.loads(unquote_plus(intersects)) - if datetime: - base_args["datetime"] = self._format_datetime_range(datetime) - if filter_expr: base_args["filter"] = self.get_filter(filter_expr, filter_lang) aggregate_request = EsAggregationExtensionPostRequest(**base_args) diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index f9c35a3a..d0256406 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -7,7 +7,7 @@ from stac_fastapi.extensions.third_party.bulk_transactions import Items from stac_fastapi.types.errors import ConflictError, NotFoundError -from stac_fastapi.types.stac import PatchOperation +from stac_fastapi.types.stac import PatchAddReplaceTest, PatchMoveCopy, PatchRemove from ..conftest import MockRequest, create_item @@ -282,7 +282,9 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "add", "path": "properties.foo", "value": "bar"}), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "properties.foo", "value": "bar"} + ), ] await txn_client.json_patch_item( @@ -305,7 +307,9 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "replace", "path": "properties.foo", "value": 100}), + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "properties.foo", "value": 100} + ), ] await txn_client.json_patch_item( @@ -328,7 +332,9 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "test", "path": "properties.foo", "value": 100}), + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "properties.foo", "value": 100} + ), ] await txn_client.json_patch_item( @@ -351,8 +357,8 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation( - **{"op": "move", "path": "properties.bar", "from": "properties.foo"} + PatchMoveCopy.model_validate( + {"op": "move", "path": "properties.bar", "from": "properties.foo"} ), ] @@ -377,8 +383,8 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation( - **{"op": "copy", "path": "properties.foo", "from": "properties.bar"} + PatchMoveCopy.model_validate( + {"op": "copy", "path": "properties.foo", "from": "properties.bar"} ), ] @@ -402,8 +408,8 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "remove", "path": "properties.foo"}), - PatchOperation(**{"op": "remove", "path": "properties.bar"}), + PatchRemove.model_validate({"op": "remove", "path": "properties.foo"}), + PatchRemove.model_validate({"op": "remove", "path": "properties.bar"}), ] await txn_client.json_patch_item( @@ -427,8 +433,8 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation( - **{"op": "test", "path": "properties.platform", "value": "landsat-9"} + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "properties.platform", "value": "landsat-9"} ), ] @@ -450,8 +456,8 @@ async def test_json_patch_item_replace_property_does_not_exists( collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation( - **{"op": "replace", "path": "properties.foo", "value": "landsat-9"} + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "properties.foo", "value": "landsat-9"} ), ] @@ -473,7 +479,7 @@ async def test_json_patch_item_remove_property_does_not_exists( collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation(**{"op": "remove", "path": "properties.foo"}), + PatchRemove.model_validate({"op": "remove", "path": "properties.foo"}), ] with pytest.raises(ConflictError): @@ -494,8 +500,8 @@ async def test_json_patch_item_move_property_does_not_exists( collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation( - **{"op": "move", "path": "properties.bar", "from": "properties.foo"} + PatchMoveCopy.model_validate( + {"op": "move", "path": "properties.bar", "from": "properties.foo"} ), ] @@ -517,8 +523,8 @@ async def test_json_patch_item_copy_property_does_not_exists( collection_id = item["collection"] item_id = item["id"] operations = [ - PatchOperation( - **{"op": "copy", "path": "properties.bar", "from": "properties.foo"} + PatchMoveCopy.model_validate( + {"op": "copy", "path": "properties.bar", "from": "properties.foo"} ), ] From 7368d8d44347ff370d53071a4074bf416f2fefde Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 14:42:42 +0100 Subject: [PATCH 24/38] patch bugs. --- .../core/stac_fastapi/core/utilities.py | 27 +++++++---- .../elasticsearch/database_logic.py | 4 +- .../stac_fastapi/opensearch/database_logic.py | 4 +- .../tests/clients/test_elasticsearch.py | 47 ++++++++++--------- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 9b928872..488437c7 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -8,7 +8,12 @@ from typing import Any, Dict, List, Optional, Set, Union from stac_fastapi.core.models.patch import ElasticPath -from stac_fastapi.types.stac import Item, PatchOperation +from stac_fastapi.types.stac import ( + Item, + PatchAddReplaceTest, + PatchOperation, + PatchRemove, +) MAX_LIMIT = 10000 @@ -152,7 +157,7 @@ def merge_to_operations(data: Dict) -> List: for key, value in data.copy().items(): if value is None: - operations.append(PatchOperation(op="remove", path=key)) + operations.append(PatchRemove(op="remove", path=key)) elif isinstance(value, dict): nested_operations = merge_to_operations(value) @@ -162,7 +167,7 @@ def merge_to_operations(data: Dict) -> List: operations.append(nested_operation) else: - operations.append(PatchOperation(op="add", path=key, value=value)) + operations.append(PatchAddReplaceTest(op="add", path=key, value=value)) return operations @@ -210,7 +215,7 @@ def copy_commands( from_path (ElasticPath): Path to copy from """ - check_commands(operation.op, from_path, True) + check_commands(commands=commands, op=operation.op, path=from_path, from_path=True) if from_path.index: commands.append( @@ -329,22 +334,24 @@ def operations_to_script(operations: List) -> Dict: ElasticPath(path=operation.from_) if hasattr(operation, "from_") else None ) - check_commands(commands, operation.op, path) + check_commands(commands=commands, op=operation.op, path=path) if operation.op in ["copy", "move"]: - copy_commands(commands, operation, path, from_path) + copy_commands( + commands=commands, operation=operation, path=path, from_path=from_path + ) if operation.op in ["remove", "move"]: remove_path = from_path if from_path else path - remove_commands(commands, remove_path) + remove_commands(commands=commands, path=remove_path) if operation.op in ["add", "replace"]: - add_commands(commands, operation, path) + add_commands(commands=commands, operation=operation, path=path) if operation.op == "test": - test_commands(commands, operation, path) + test_commands(commands=commands, operation=operation, path=path) - source = commands_to_source(commands) + source = commands_to_source(commands=commands) return { "source": source, diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 4547e319..5574802e 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -987,7 +987,9 @@ async def json_patch_item( ) except exceptions.BadRequestError as exc: - raise KeyError(exc.info["error"]["caused_by"]["to_string"]) from exc + raise exceptions.BadRequestError( + exc.info["error"]["caused_by"]["to_string"] + ) from exc item = await self.get_one_item(collection_id, item_id) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 6949bd06..20a237c7 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1019,7 +1019,9 @@ async def json_patch_item( ) except exceptions.BadRequestError as exc: - raise KeyError(exc.info["error"]["caused_by"]["to_string"]) from exc + raise exceptions.BadRequestError( + exc.info["error"]["caused_by"]["to_string"] + ) from exc item = await self.get_one_item(collection_id, item_id) diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index d0256406..7232a2d4 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -1,3 +1,4 @@ +import os import uuid from copy import deepcopy from typing import Callable @@ -5,6 +6,11 @@ import pytest from stac_pydantic import Item, api +if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch": + from elasticsearch import exceptions +else: + from opensearchpy import exceptions + from stac_fastapi.extensions.third_party.bulk_transactions import Items from stac_fastapi.types.errors import ConflictError, NotFoundError from stac_fastapi.types.stac import PatchAddReplaceTest, PatchMoveCopy, PatchRemove @@ -265,15 +271,14 @@ async def test_merge_patch_item_remove(ctx, core_client, txn_client): await txn_client.merge_patch_item( collection_id=collection_id, item_id=item_id, - item={"properties": {"foo": None, "hello": None}}, + item={"properties": {"gsd": None}}, request=MockRequest, ) updated_item = await core_client.get_item( item_id, collection_id, request=MockRequest ) - assert "foo" not in updated_item["properties"] - assert "hello" not in updated_item["properties"] + assert "gsd" not in updated_item["properties"] @pytest.mark.asyncio @@ -298,7 +303,7 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert updated_item["properties"]["bar"] == "foo" + assert updated_item["properties"]["foo"] == "bar" @pytest.mark.asyncio @@ -308,7 +313,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "properties.foo", "value": 100} + {"op": "replace", "path": "properties.gsd", "value": 100} ), ] @@ -323,7 +328,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert updated_item["properties"]["foo"] == 100 + assert updated_item["properties"]["gsd"] == 100 @pytest.mark.asyncio @@ -333,7 +338,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "test", "path": "properties.foo", "value": 100} + {"op": "test", "path": "properties.gsd", "value": 15} ), ] @@ -348,7 +353,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert updated_item["properties"]["foo"] == 100 + assert updated_item["properties"]["gsd"] == 15 @pytest.mark.asyncio @@ -358,7 +363,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "move", "path": "properties.bar", "from": "properties.foo"} + {"op": "move", "path": "properties.bar", "from": "properties.gsd"} ), ] @@ -373,8 +378,8 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert updated_item["properties"]["bar"] == 100 - assert "foo" not in updated_item["properties"] + assert updated_item["properties"]["bar"] == 15 + assert "gsd" not in updated_item["properties"] @pytest.mark.asyncio @@ -384,7 +389,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "copy", "path": "properties.foo", "from": "properties.bar"} + {"op": "copy", "path": "properties.foo", "from": "properties.gsd"} ), ] @@ -399,7 +404,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert updated_item["properties"]["foo"] == updated_item["properties"]["bar"] + assert updated_item["properties"]["foo"] == updated_item["properties"]["gsd"] @pytest.mark.asyncio @@ -408,8 +413,7 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchRemove.model_validate({"op": "remove", "path": "properties.foo"}), - PatchRemove.model_validate({"op": "remove", "path": "properties.bar"}), + PatchRemove.model_validate({"op": "remove", "path": "properties.gsd"}), ] await txn_client.json_patch_item( @@ -423,8 +427,7 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert "foo" not in updated_item["properties"] - assert "bar" not in updated_item["properties"] + assert "gsd" not in updated_item["properties"] @pytest.mark.asyncio @@ -438,7 +441,7 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): ), ] - with pytest.raises(ConflictError): + with pytest.raises(exceptions.BadRequestError): await txn_client.json_patch_item( collection_id=collection_id, @@ -461,7 +464,7 @@ async def test_json_patch_item_replace_property_does_not_exists( ), ] - with pytest.raises(ConflictError): + with pytest.raises(exceptions.BadRequestError): await txn_client.json_patch_item( collection_id=collection_id, @@ -482,7 +485,7 @@ async def test_json_patch_item_remove_property_does_not_exists( PatchRemove.model_validate({"op": "remove", "path": "properties.foo"}), ] - with pytest.raises(ConflictError): + with pytest.raises(exceptions.BadRequestError): await txn_client.json_patch_item( collection_id=collection_id, @@ -505,7 +508,7 @@ async def test_json_patch_item_move_property_does_not_exists( ), ] - with pytest.raises(ConflictError): + with pytest.raises(exceptions.BadRequestError): await txn_client.json_patch_item( collection_id=collection_id, @@ -528,7 +531,7 @@ async def test_json_patch_item_copy_property_does_not_exists( ), ] - with pytest.raises(ConflictError): + with pytest.raises(exceptions.BadRequestError): await txn_client.json_patch_item( collection_id=collection_id, From 2b359f67f2ac801fb788e8cfdb2c26885a725911 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 15:11:33 +0100 Subject: [PATCH 25/38] Switch to http exception. --- .../elasticsearch/database_logic.py | 5 +++-- .../stac_fastapi/opensearch/database_logic.py | 5 +++-- .../tests/clients/test_elasticsearch.py | 17 ++++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 5574802e..fcd4eef4 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -10,6 +10,7 @@ import attr from elasticsearch_dsl import Q, Search +from fastapi import HTTPException from starlette.requests import Request from elasticsearch import exceptions, helpers # type: ignore @@ -987,8 +988,8 @@ async def json_patch_item( ) except exceptions.BadRequestError as exc: - raise exceptions.BadRequestError( - exc.info["error"]["caused_by"]["to_string"] + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] ) from exc item = await self.get_one_item(collection_id, item_id) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 20a237c7..b729222c 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Iterable, List, Optional, Protocol, Tuple, Type, Union import attr +from fastapi import HTTPException from opensearchpy import exceptions, helpers from opensearchpy.exceptions import TransportError from opensearchpy.helpers.query import Q @@ -1019,8 +1020,8 @@ async def json_patch_item( ) except exceptions.BadRequestError as exc: - raise exceptions.BadRequestError( - exc.info["error"]["caused_by"]["to_string"] + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] ) from exc item = await self.get_one_item(collection_id, item_id) diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 7232a2d4..45fb3b20 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -1,16 +1,11 @@ -import os import uuid from copy import deepcopy from typing import Callable import pytest +from fastapi import HTTPException from stac_pydantic import Item, api -if os.getenv("BACKEND", "elasticsearch").lower() == "opensearch": - from elasticsearch import exceptions -else: - from opensearchpy import exceptions - from stac_fastapi.extensions.third_party.bulk_transactions import Items from stac_fastapi.types.errors import ConflictError, NotFoundError from stac_fastapi.types.stac import PatchAddReplaceTest, PatchMoveCopy, PatchRemove @@ -441,7 +436,7 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): ), ] - with pytest.raises(exceptions.BadRequestError): + with pytest.raises(HTTPException): await txn_client.json_patch_item( collection_id=collection_id, @@ -464,7 +459,7 @@ async def test_json_patch_item_replace_property_does_not_exists( ), ] - with pytest.raises(exceptions.BadRequestError): + with pytest.raises(HTTPException): await txn_client.json_patch_item( collection_id=collection_id, @@ -485,7 +480,7 @@ async def test_json_patch_item_remove_property_does_not_exists( PatchRemove.model_validate({"op": "remove", "path": "properties.foo"}), ] - with pytest.raises(exceptions.BadRequestError): + with pytest.raises(HTTPException): await txn_client.json_patch_item( collection_id=collection_id, @@ -508,7 +503,7 @@ async def test_json_patch_item_move_property_does_not_exists( ), ] - with pytest.raises(exceptions.BadRequestError): + with pytest.raises(HTTPException): await txn_client.json_patch_item( collection_id=collection_id, @@ -531,7 +526,7 @@ async def test_json_patch_item_copy_property_does_not_exists( ), ] - with pytest.raises(exceptions.BadRequestError): + with pytest.raises(HTTPException): await txn_client.json_patch_item( collection_id=collection_id, From 0274b6448947f0c47ede24b02b7aac1f08cb4bbc Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 15:57:59 +0100 Subject: [PATCH 26/38] test_item_search_temporal_window_get fix. --- stac_fastapi/tests/resources/test_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/tests/resources/test_item.py b/stac_fastapi/tests/resources/test_item.py index 904adbbf..f92b36da 100644 --- a/stac_fastapi/tests/resources/test_item.py +++ b/stac_fastapi/tests/resources/test_item.py @@ -483,7 +483,7 @@ async def test_item_search_temporal_window_timezone_get( item_date = rfc3339_str_to_datetime(test_item["properties"]["datetime"]) item_date_before = item_date - timedelta(seconds=1) item_date_before = item_date_before.replace(tzinfo=tzinfo) - item_date_after = item_date + timedelta(seconds=1) + item_date_after = item_date + timedelta(hours=1, seconds=1) item_date_after = item_date_after.replace(tzinfo=tzinfo) params = { From 1c4fe43aa185213e67dc8b7a6177372b6a8ff4e3 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 16:03:15 +0100 Subject: [PATCH 27/38] Request not BadRequest for opensearch. --- .../opensearch/stac_fastapi/opensearch/database_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index b729222c..99e027f1 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1019,7 +1019,7 @@ async def json_patch_item( refresh=True, ) - except exceptions.BadRequestError as exc: + except exceptions.RequestError as exc: raise HTTPException( status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] ) from exc From fb6fe822683cd6cc4637a5463d56d62c929322df Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Mon, 31 Mar 2025 16:44:08 +0100 Subject: [PATCH 28/38] Opensearch update body. --- .../opensearch/stac_fastapi/opensearch/database_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 99e027f1..d326a8d0 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1015,7 +1015,7 @@ async def json_patch_item( await self.client.update( index=index_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), - script=script, + body={"script": script}, refresh=True, ) From fa6afb3bfbd9afe8d96298f83d63d9b51015acbb Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Tue, 1 Apr 2025 10:17:29 +0100 Subject: [PATCH 29/38] Adding collection patch tests. --- stac_fastapi/core/stac_fastapi/core/core.py | 1 + .../core/stac_fastapi/core/models/patch.py | 23 +- .../elasticsearch/database_logic.py | 18 +- .../stac_fastapi/opensearch/database_logic.py | 13 +- .../tests/clients/test_elasticsearch.py | 353 +++++- stac_fastapi/tests/data/test_item.json | 1020 +++++++++-------- 6 files changed, 890 insertions(+), 538 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/core.py b/stac_fastapi/core/stac_fastapi/core/core.py index d3d8ef3d..ba4282ac 100644 --- a/stac_fastapi/core/stac_fastapi/core/core.py +++ b/stac_fastapi/core/stac_fastapi/core/core.py @@ -916,6 +916,7 @@ async def json_patch_collection( operations=operations, base_url=str(kwargs["request"].base_url), ) + return CollectionSerializer.db_to_stac( collection, kwargs["request"], diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index af2fb39a..069337fb 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -1,6 +1,6 @@ """patch helpers.""" -from typing import Any, Optional +from typing import Any, Optional, Union from pydantic import BaseModel, computed_field, model_validator @@ -17,7 +17,7 @@ class ElasticPath(BaseModel): nest: Optional[str] = None partition: Optional[str] = None key: Optional[str] = None - index: Optional[int] = None + _index: Optional[int] = None @model_validator(mode="before") @classmethod @@ -31,13 +31,26 @@ def validate_model(cls, data: Any): data["nest"], data["partition"], data["key"] = data["path"].rpartition(".") - if data["key"].isdigit(): - data["index"] = int(data["key"]) - data["path"] = f"{data['nest']}[{data['index']}]" + if data["key"].isdigit() or data["key"] == "-": + data["_index"] = -1 if data["key"] == "-" else int(data["key"]) data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".") + data["path"] = f"{data['nest']}[{data['_index']}]" return data + @property + def index(self) -> Union[int, str, None]: + """Compute location of path. + + Returns: + str: path location + """ + if self._index and self._index < 0: + + return f"ctx._source.{self.location}.size() - {-self._index}" + + return self._index + @computed_field # type: ignore[misc] @property def location(self) -> str: diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index fcd4eef4..3c4e4e17 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -1216,12 +1216,18 @@ async def json_patch_collection( script = operations_to_script(script_operations) - await self.client.update( - index=COLLECTIONS_INDEX, - id=collection_id, - script=script, - refresh=True, - ) + try: + await self.client.update( + index=COLLECTIONS_INDEX, + id=collection_id, + script=script, + refresh=True, + ) + + except exceptions.BadRequestError as exc: + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] + ) from exc collection = await self.find_collection(collection_id) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index d326a8d0..7fe9bcaf 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1013,7 +1013,7 @@ async def json_patch_item( try: await self.client.update( - index=index_by_collection_id(collection_id), + index=index_alias_by_collection_id(collection_id), id=mk_item_id(item_id, collection_id), body={"script": script}, refresh=True, @@ -1248,14 +1248,19 @@ async def json_patch_collection( script = operations_to_script(script_operations) - if not new_collection_id: + try: await self.client.update( index=COLLECTIONS_INDEX, id=collection_id, - body={"script": script}, - refresh=refresh, + script=script, + refresh=True, ) + except exceptions.BadRequestError as exc: + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] + ) from exc + collection = await self.find_collection(collection_id) if new_collection_id: diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 45fb3b20..a0042eb8 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -247,7 +247,7 @@ async def test_merge_patch_item_add(ctx, core_client, txn_client): await txn_client.merge_patch_item( collection_id=collection_id, item_id=item_id, - item={"properties": {"foo": "bar", "hello": "world"}}, + item={"properties": {"foo": "bar", "ext:hello": "world"}}, request=MockRequest, ) @@ -255,7 +255,7 @@ async def test_merge_patch_item_add(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) assert updated_item["properties"]["foo"] == "bar" - assert updated_item["properties"]["hello"] == "world" + assert updated_item["properties"]["ext:hello"] == "world" @pytest.mark.asyncio @@ -266,7 +266,7 @@ async def test_merge_patch_item_remove(ctx, core_client, txn_client): await txn_client.merge_patch_item( collection_id=collection_id, item_id=item_id, - item={"properties": {"gsd": None}}, + item={"properties": {"gsd": None, "proj:epsg": None}}, request=MockRequest, ) @@ -274,6 +274,7 @@ async def test_merge_patch_item_remove(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) assert "gsd" not in updated_item["properties"] + assert "proj:epsg" not in updated_item["properties"] @pytest.mark.asyncio @@ -283,7 +284,13 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "add", "path": "properties.foo", "value": "bar"} + {"op": "add", "path": "/properties/foo", "value": "bar"} + ), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/properties/ext:hello", "value": "world"} + ), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/properties/area/1", "value": 10} ), ] @@ -299,6 +306,8 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): ) assert updated_item["properties"]["foo"] == "bar" + assert updated_item["properties"]["ext:hello"] == "world" + assert updated_item["properties"]["area"] == [2500, -100, 10] @pytest.mark.asyncio @@ -308,7 +317,13 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "properties.gsd", "value": 100} + {"op": "replace", "path": "/properties/gsd", "value": 100} + ), + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/properties/proj:epsg", "value": "world"} + ), + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "/properties/area/1", "value": "50"} ), ] @@ -324,6 +339,8 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): ) assert updated_item["properties"]["gsd"] == 100 + assert updated_item["properties"]["proj:epsg"] == 100 + assert updated_item["properties"]["area"] == [2500, 50] @pytest.mark.asyncio @@ -333,7 +350,13 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "test", "path": "properties.gsd", "value": 15} + {"op": "test", "path": "/properties/gsd", "value": 15} + ), + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/properties/proj:epsg", "value": 32756} + ), + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "/properties/area/1", "value": -100} ), ] @@ -349,6 +372,8 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): ) assert updated_item["properties"]["gsd"] == 15 + assert updated_item["properties"]["proj:epsg"] == 32756 + assert updated_item["properties"]["area"][1] == -100 @pytest.mark.asyncio @@ -358,7 +383,13 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "move", "path": "properties.bar", "from": "properties.gsd"} + {"op": "move", "path": "/properties/foo", "from": "/properties/gsd"} + ), + PatchMoveCopy.model_validate( + {"op": "move", "path": "/properties/bar", "from": "/properties/proj:epsg"} + ), + PatchMoveCopy.model_validate( + {"op": "move", "path": "/properties/hello", "from": "/properties/area/1"} ), ] @@ -373,8 +404,12 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): item_id, collection_id, request=MockRequest ) - assert updated_item["properties"]["bar"] == 15 + assert updated_item["properties"]["foo"] == 15 assert "gsd" not in updated_item["properties"] + assert updated_item["properties"]["bar"] == 32756 + assert "proj:epsg" not in updated_item["properties"] + assert updated_item["properties"]["hello"] == [-100] + assert updated_item["properties"]["area"] == [2500] @pytest.mark.asyncio @@ -384,7 +419,13 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "copy", "path": "properties.foo", "from": "properties.gsd"} + {"op": "copy", "path": "/properties/foo", "from": "/properties/gsd"} + ), + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/properties/bar", "from": "/properties/proj:epsg"} + ), + PatchMoveCopy.model_validate( + {"op": "copy", "path": "/properties/hello", "from": "/properties/area/1"} ), ] @@ -400,6 +441,8 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): ) assert updated_item["properties"]["foo"] == updated_item["properties"]["gsd"] + assert updated_item["properties"]["bar"] == updated_item["properties"]["proj:epsg"] + assert updated_item["properties"]["hello"] == updated_item["properties"]["area"][1] @pytest.mark.asyncio @@ -408,7 +451,9 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): collection_id = item["collection"] item_id = item["id"] operations = [ - PatchRemove.model_validate({"op": "remove", "path": "properties.gsd"}), + PatchRemove.model_validate({"op": "remove", "path": "/properties/gsd"}), + PatchRemove.model_validate({"op": "remove", "path": "/properties/proj:epsg"}), + PatchRemove.model_validate({"op": "remove", "path": "/properties/area/1"}), ] await txn_client.json_patch_item( @@ -423,6 +468,8 @@ async def test_json_patch_item_remove(ctx, core_client, txn_client): ) assert "gsd" not in updated_item["properties"] + assert "proj:epsg" not in updated_item["properties"] + assert updated_item["properties"]["area"] == [2500] @pytest.mark.asyncio @@ -432,7 +479,7 @@ async def test_json_patch_item_test_wrong_value(ctx, core_client, txn_client): item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "test", "path": "properties.platform", "value": "landsat-9"} + {"op": "test", "path": "/properties/platform", "value": "landsat-9"} ), ] @@ -455,7 +502,7 @@ async def test_json_patch_item_replace_property_does_not_exists( item_id = item["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "properties.foo", "value": "landsat-9"} + {"op": "replace", "path": "/properties/foo", "value": "landsat-9"} ), ] @@ -477,7 +524,7 @@ async def test_json_patch_item_remove_property_does_not_exists( collection_id = item["collection"] item_id = item["id"] operations = [ - PatchRemove.model_validate({"op": "remove", "path": "properties.foo"}), + PatchRemove.model_validate({"op": "remove", "path": "/properties/foo"}), ] with pytest.raises(HTTPException): @@ -499,7 +546,7 @@ async def test_json_patch_item_move_property_does_not_exists( item_id = item["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "move", "path": "properties.bar", "from": "properties.foo"} + {"op": "move", "path": "/properties/bar", "from": "/properties/foo"} ), ] @@ -522,7 +569,7 @@ async def test_json_patch_item_copy_property_does_not_exists( item_id = item["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "copy", "path": "properties.bar", "from": "properties.foo"} + {"op": "copy", "path": "/properties/bar", "from": "/properties/foo"} ), ] @@ -628,3 +675,279 @@ async def test_landing_page_no_collection_title(ctx, core_client, txn_client, ap for link in landing_page["links"]: if link["href"].split("/")[-1] == ctx.collection["id"]: assert link["title"] + + +@pytest.mark.asyncio +async def test_merge_patch_collection_add(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + + await txn_client.merge_patch_collection( + collection_id=collection_id, + collection={"summaries": {"foo": "bar", "hello": "world"}}, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + assert updated_collection["summaries"]["foo"] == "bar" + assert updated_collection["summaries"]["hello"] == "world" + + +@pytest.mark.asyncio +async def test_merge_patch_collection_remove(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + await txn_client.merge_patch_collection( + collection_id=collection_id, + collection={"summaries": {"gsd": None}}, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + assert "gsd" not in updated_collection["summaries"] + + +@pytest.mark.asyncio +async def test_json_patch_collection_add(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "summaries.foo", "value": "bar"}, + {"op": "add", "path": "summaries.gsd.1", "value": 100}, + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["foo"] == "bar" + assert updated_collection["summaries"]["gsd"] == [15, 100] + + +@pytest.mark.asyncio +async def test_json_patch_collection_replace(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "summaries.gsd", "value": [100]} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["gsd"] == 100 + + +@pytest.mark.asyncio +async def test_json_patch_collection_test(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "summaries.gsd", "value": 15} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["gsd"] == 15 + + +@pytest.mark.asyncio +async def test_json_patch_collection_move(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "move", "path": "summaries.bar", "from": "summaries.gsd"} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert updated_collection["summaries"]["bar"] == [15] + assert "gsd" not in updated_collection["summaries"] + + +@pytest.mark.asyncio +async def test_json_patch_collection_copy(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "copy", "path": "summaries.foo", "from": "summaries.gsd"} + ), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert ( + updated_collection["summaries"]["foo"] == updated_collection["summaries"]["gsd"] + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_remove(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchRemove.model_validate({"op": "remove", "path": "summaries.gsd"}), + ] + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + updated_collection = await core_client.get_collection( + collection_id, request=MockRequest + ) + + assert "gsd" not in updated_collection["summaries"] + + +@pytest.mark.asyncio +async def test_json_patch_collection_test_wrong_value(ctx, core_client, txn_client): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "test", "path": "summaries.platform", "value": "landsat-9"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_replace_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchAddReplaceTest.model_validate( + {"op": "replace", "path": "summaries.foo", "value": "landsat-9"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_remove_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchRemove.model_validate({"op": "remove", "path": "summaries.foo"}), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_move_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "move", "path": "summaries.bar", "from": "summaries.foo"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) + + +@pytest.mark.asyncio +async def test_json_patch_collection_copy_property_does_not_exists( + ctx, core_client, txn_client +): + collection = ctx.collection + collection_id = collection["id"] + operations = [ + PatchMoveCopy.model_validate( + {"op": "copy", "path": "summaries.bar", "from": "summaries.foo"} + ), + ] + + with pytest.raises(HTTPException): + + await txn_client.json_patch_collection( + collection_id=collection_id, + operations=operations, + request=MockRequest, + ) diff --git a/stac_fastapi/tests/data/test_item.json b/stac_fastapi/tests/data/test_item.json index f3d78da8..c311732b 100644 --- a/stac_fastapi/tests/data/test_item.json +++ b/stac_fastapi/tests/data/test_item.json @@ -1,510 +1,514 @@ { - "type": "Feature", - "id": "test-item", - "stac_version": "1.0.0", - "stac_extensions": [ - "https://stac-extensions.github.io/eo/v1.0.0/schema.json", - "https://stac-extensions.github.io/projection/v1.0.0/schema.json" - ], - "geometry": { - "coordinates": [ - [ - [ - 152.15052873427666, - -33.82243006904891 - ], - [ - 150.1000346138806, - -34.257132625788756 - ], - [ - 149.5776607193635, - -32.514709769700254 - ], - [ - 151.6262528041627, - -32.08081674221862 - ], - [ - 152.15052873427666, - -33.82243006904891 - ] - ] - ], - "type": "Polygon" - }, - "properties": { - "datetime": "2020-02-12T12:30:22Z", - "landsat:scene_id": "LC82081612020043LGN00", - "landsat:row": "161", - "gsd": 15, - "eo:bands": [ - { - "gsd": 30, - "name": "B1", - "common_name": "coastal", - "center_wavelength": 0.44, - "full_width_half_max": 0.02 - }, - { - "gsd": 30, - "name": "B2", - "common_name": "blue", - "center_wavelength": 0.48, - "full_width_half_max": 0.06 - }, - { - "gsd": 30, - "name": "B3", - "common_name": "green", - "center_wavelength": 0.56, - "full_width_half_max": 0.06 - }, - { - "gsd": 30, - "name": "B4", - "common_name": "red", - "center_wavelength": 0.65, - "full_width_half_max": 0.04 - }, - { - "gsd": 30, - "name": "B5", - "common_name": "nir", - "center_wavelength": 0.86, - "full_width_half_max": 0.03 - }, - { - "gsd": 30, - "name": "B6", - "common_name": "swir16", - "center_wavelength": 1.6, - "full_width_half_max": 0.08 - }, - { - "gsd": 30, - "name": "B7", - "common_name": "swir22", - "center_wavelength": 2.2, - "full_width_half_max": 0.2 - }, - { - "gsd": 15, - "name": "B8", - "common_name": "pan", - "center_wavelength": 0.59, - "full_width_half_max": 0.18 - }, - { - "gsd": 30, - "name": "B9", - "common_name": "cirrus", - "center_wavelength": 1.37, - "full_width_half_max": 0.02 - }, - { - "gsd": 100, - "name": "B10", - "common_name": "lwir11", - "center_wavelength": 10.9, - "full_width_half_max": 0.8 - }, - { - "gsd": 100, - "name": "B11", - "common_name": "lwir12", - "center_wavelength": 12, - "full_width_half_max": 1 - } - ], - "landsat:revision": "00", - "view:sun_azimuth": -148.83296771, - "instrument": "OLI_TIRS", - "landsat:product_id": "LC08_L1GT_208161_20200212_20200212_01_RT", - "eo:cloud_cover": 0, - "landsat:tier": "RT", - "landsat:processing_level": "L1GT", - "landsat:column": "208", - "platform": "landsat-8", - "proj:epsg": 32756, - "view:sun_elevation": -37.30791534, - "view:off_nadir": 0, - "height": 2500, - "width": 2500, - "proj:centroid": { - "lat": -33.168923093262876, - "lon": 150.86362466374058 - }, - "grid:code": "MGRS-56HLJ" - }, - "bbox": [ - 149.57574, - -34.25796, - 152.15194, - -32.07915 - ], - "collection": "test-collection", - "assets": { - "ANG": { - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ANG.txt", - "type": "text/plain", - "title": "Angle Coefficients File", - "description": "Collection 2 Level-1 Angle Coefficients File (ANG)" - }, - "SR_B1": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B1.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Coastal/Aerosol Band (B1)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B1", - "common_name": "coastal", - "center_wavelength": 0.44, - "full_width_half_max": 0.02 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Coastal/Aerosol Band (B1) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B2": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B2.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Blue Band (B2)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B2", - "common_name": "blue", - "center_wavelength": 0.48, - "full_width_half_max": 0.06 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Blue Band (B2) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B3": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B3.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Green Band (B3)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B3", - "common_name": "green", - "center_wavelength": 0.56, - "full_width_half_max": 0.06 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Green Band (B3) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B4": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B4.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Red Band (B4)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B4", - "common_name": "red", - "center_wavelength": 0.65, - "full_width_half_max": 0.04 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Red Band (B4) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B5": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B5.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Near Infrared Band 0.8 (B5)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B5", - "common_name": "nir08", - "center_wavelength": 0.86, - "full_width_half_max": 0.03 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Near Infrared Band 0.8 (B5) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B6": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B6.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Short-wave Infrared Band 1.6 (B6)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B6", - "common_name": "swir16", - "center_wavelength": 1.6, - "full_width_half_max": 0.08 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Short-wave Infrared Band 1.6 (B6) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "SR_B7": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B7.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Short-wave Infrared Band 2.2 (B7)", - "eo:bands": [ - { - "gsd": 30, - "name": "SR_B7", - "common_name": "swir22", - "center_wavelength": 2.2, - "full_width_half_max": 0.2 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Collection 2 Level-2 Short-wave Infrared Band 2.2 (B7) Surface Reflectance", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_QA": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_QA.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Surface Temperature Quality Assessment Band", - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Surface Temperature Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_B10": { - "gsd": 100, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_B10.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Surface Temperature Band (B10)", - "eo:bands": [ - { - "gsd": 100, - "name": "ST_B10", - "common_name": "lwir11", - "center_wavelength": 10.9, - "full_width_half_max": 0.8 - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Surface Temperature Band (B10) Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "MTL.txt": { - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.txt", - "type": "text/plain", - "title": "Product Metadata File", - "description": "Collection 2 Level-1 Product Metadata File (MTL)" - }, - "MTL.xml": { - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.xml", - "type": "application/xml", - "title": "Product Metadata File (xml)", - "description": "Collection 2 Level-1 Product Metadata File (xml)" - }, - "ST_DRAD": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_DRAD.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Downwelled Radiance Band", - "eo:bands": [ - { - "gsd": 30, - "name": "ST_DRAD", - "description": "downwelled radiance" - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Downwelled Radiance Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_EMIS": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMIS.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Emissivity Band", - "eo:bands": [ - { - "gsd": 30, - "name": "ST_EMIS", - "description": "emissivity" - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Emissivity Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - }, - "ST_EMSD": { - "gsd": 30, - "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMSD.TIF", - "type": "image/tiff; application=geotiff; profile=cloud-optimized", - "title": "Emissivity Standard Deviation Band", - "eo:bands": [ - { - "gsd": 30, - "name": "ST_EMSD", - "description": "emissivity standard deviation" - } - ], - "proj:shape": [ - 7731, - 7591 - ], - "description": "Landsat Collection 2 Level-2 Emissivity Standard Deviation Band Surface Temperature Product", - "proj:transform": [ - 30, - 0, - 304185, - 0, - -30, - -843585 - ] - } - }, - "links": [ - { - "href": "http://localhost:8081/collections/landsat-8-l1/items/LC82081612020043", - "rel": "self", - "type": "application/geo+json" - }, - { - "href": "http://localhost:8081/collections/landsat-8-l1", - "rel": "parent", - "type": "application/json" - }, - { - "href": "http://localhost:8081/collections/landsat-8-l1", - "rel": "collection", - "type": "application/json" - }, - { - "href": "http://localhost:8081/", - "rel": "root", - "type": "application/json" - } - ] + "type": "Feature", + "id": "test-item", + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/eo/v1.0.0/schema.json", + "https://stac-extensions.github.io/projection/v1.0.0/schema.json" + ], + "geometry": { + "coordinates": [ + [ + [ + 152.15052873427666, + -33.82243006904891 + ], + [ + 150.1000346138806, + -34.257132625788756 + ], + [ + 149.5776607193635, + -32.514709769700254 + ], + [ + 151.6262528041627, + -32.08081674221862 + ], + [ + 152.15052873427666, + -33.82243006904891 + ] + ] + ], + "type": "Polygon" + }, + "properties": { + "datetime": "2020-02-12T12:30:22Z", + "landsat:scene_id": "LC82081612020043LGN00", + "landsat:row": "161", + "gsd": 15, + "eo:bands": [ + { + "gsd": 30, + "name": "B1", + "common_name": "coastal", + "center_wavelength": 0.44, + "full_width_half_max": 0.02 + }, + { + "gsd": 30, + "name": "B2", + "common_name": "blue", + "center_wavelength": 0.48, + "full_width_half_max": 0.06 + }, + { + "gsd": 30, + "name": "B3", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.06 + }, + { + "gsd": 30, + "name": "B4", + "common_name": "red", + "center_wavelength": 0.65, + "full_width_half_max": 0.04 + }, + { + "gsd": 30, + "name": "B5", + "common_name": "nir", + "center_wavelength": 0.86, + "full_width_half_max": 0.03 + }, + { + "gsd": 30, + "name": "B6", + "common_name": "swir16", + "center_wavelength": 1.6, + "full_width_half_max": 0.08 + }, + { + "gsd": 30, + "name": "B7", + "common_name": "swir22", + "center_wavelength": 2.2, + "full_width_half_max": 0.2 + }, + { + "gsd": 15, + "name": "B8", + "common_name": "pan", + "center_wavelength": 0.59, + "full_width_half_max": 0.18 + }, + { + "gsd": 30, + "name": "B9", + "common_name": "cirrus", + "center_wavelength": 1.37, + "full_width_half_max": 0.02 + }, + { + "gsd": 100, + "name": "B10", + "common_name": "lwir11", + "center_wavelength": 10.9, + "full_width_half_max": 0.8 + }, + { + "gsd": 100, + "name": "B11", + "common_name": "lwir12", + "center_wavelength": 12, + "full_width_half_max": 1 + } + ], + "landsat:revision": "00", + "view:sun_azimuth": -148.83296771, + "instrument": "OLI_TIRS", + "landsat:product_id": "LC08_L1GT_208161_20200212_20200212_01_RT", + "eo:cloud_cover": 0, + "landsat:tier": "RT", + "landsat:processing_level": "L1GT", + "landsat:column": "208", + "platform": "landsat-8", + "proj:epsg": 32756, + "view:sun_elevation": -37.30791534, + "view:off_nadir": 0, + "height": 2500, + "width": 2500, + "area": [ + 2500, + -200 + ], + "proj:centroid": { + "lat": -33.168923093262876, + "lon": 150.86362466374058 + }, + "grid:code": "MGRS-56HLJ" + }, + "bbox": [ + 149.57574, + -34.25796, + 152.15194, + -32.07915 + ], + "collection": "test-collection", + "assets": { + "ANG": { + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ANG.txt", + "type": "text/plain", + "title": "Angle Coefficients File", + "description": "Collection 2 Level-1 Angle Coefficients File (ANG)" + }, + "SR_B1": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B1.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Coastal/Aerosol Band (B1)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B1", + "common_name": "coastal", + "center_wavelength": 0.44, + "full_width_half_max": 0.02 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Coastal/Aerosol Band (B1) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B2": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B2.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Blue Band (B2)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B2", + "common_name": "blue", + "center_wavelength": 0.48, + "full_width_half_max": 0.06 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Blue Band (B2) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B3": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B3.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Green Band (B3)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B3", + "common_name": "green", + "center_wavelength": 0.56, + "full_width_half_max": 0.06 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Green Band (B3) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B4": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B4.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Red Band (B4)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B4", + "common_name": "red", + "center_wavelength": 0.65, + "full_width_half_max": 0.04 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Red Band (B4) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B5": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B5.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Near Infrared Band 0.8 (B5)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B5", + "common_name": "nir08", + "center_wavelength": 0.86, + "full_width_half_max": 0.03 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Near Infrared Band 0.8 (B5) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B6": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B6.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Short-wave Infrared Band 1.6 (B6)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B6", + "common_name": "swir16", + "center_wavelength": 1.6, + "full_width_half_max": 0.08 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Short-wave Infrared Band 1.6 (B6) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "SR_B7": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_SR_B7.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Short-wave Infrared Band 2.2 (B7)", + "eo:bands": [ + { + "gsd": 30, + "name": "SR_B7", + "common_name": "swir22", + "center_wavelength": 2.2, + "full_width_half_max": 0.2 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Collection 2 Level-2 Short-wave Infrared Band 2.2 (B7) Surface Reflectance", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_QA": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_QA.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Surface Temperature Quality Assessment Band", + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Surface Temperature Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_B10": { + "gsd": 100, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_B10.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Surface Temperature Band (B10)", + "eo:bands": [ + { + "gsd": 100, + "name": "ST_B10", + "common_name": "lwir11", + "center_wavelength": 10.9, + "full_width_half_max": 0.8 + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Surface Temperature Band (B10) Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "MTL.txt": { + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.txt", + "type": "text/plain", + "title": "Product Metadata File", + "description": "Collection 2 Level-1 Product Metadata File (MTL)" + }, + "MTL.xml": { + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_MTL.xml", + "type": "application/xml", + "title": "Product Metadata File (xml)", + "description": "Collection 2 Level-1 Product Metadata File (xml)" + }, + "ST_DRAD": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_DRAD.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Downwelled Radiance Band", + "eo:bands": [ + { + "gsd": 30, + "name": "ST_DRAD", + "description": "downwelled radiance" + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Downwelled Radiance Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_EMIS": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMIS.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Emissivity Band", + "eo:bands": [ + { + "gsd": 30, + "name": "ST_EMIS", + "description": "emissivity" + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Emissivity Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + }, + "ST_EMSD": { + "gsd": 30, + "href": "https://landsateuwest.blob.core.windows.net/landsat-c2/level-2/standard/oli-tirs/2021/108/066/LC08_L2SP_108066_20210712_20210720_02_T1/LC08_L2SP_108066_20210712_20210720_02_T1_ST_EMSD.TIF", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "Emissivity Standard Deviation Band", + "eo:bands": [ + { + "gsd": 30, + "name": "ST_EMSD", + "description": "emissivity standard deviation" + } + ], + "proj:shape": [ + 7731, + 7591 + ], + "description": "Landsat Collection 2 Level-2 Emissivity Standard Deviation Band Surface Temperature Product", + "proj:transform": [ + 30, + 0, + 304185, + 0, + -30, + -843585 + ] + } + }, + "links": [ + { + "href": "http://localhost:8081/collections/landsat-8-l1/items/LC82081612020043", + "rel": "self", + "type": "application/geo+json" + }, + { + "href": "http://localhost:8081/collections/landsat-8-l1", + "rel": "parent", + "type": "application/json" + }, + { + "href": "http://localhost:8081/collections/landsat-8-l1", + "rel": "collection", + "type": "application/json" + }, + { + "href": "http://localhost:8081/", + "rel": "root", + "type": "application/json" + } + ] } \ No newline at end of file From b1aa2522ff49ad3b4213830a496c6586f63439dc Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Tue, 1 Apr 2025 14:06:47 +0100 Subject: [PATCH 30/38] Array list fixes. --- .../core/stac_fastapi/core/models/patch.py | 16 +++++----- .../core/stac_fastapi/core/utilities.py | 2 +- .../tests/clients/test_elasticsearch.py | 32 ++++++++++--------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index 069337fb..ae300316 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -17,7 +17,7 @@ class ElasticPath(BaseModel): nest: Optional[str] = None partition: Optional[str] = None key: Optional[str] = None - _index: Optional[int] = None + index_: Optional[int] = None @model_validator(mode="before") @classmethod @@ -28,16 +28,16 @@ def validate_model(cls, data: Any): data (Any): input data """ data["path"] = data["path"].lstrip("/").replace("/", ".") - data["nest"], data["partition"], data["key"] = data["path"].rpartition(".") - if data["key"].isdigit() or data["key"] == "-": - data["_index"] = -1 if data["key"] == "-" else int(data["key"]) + if data["key"].lstrip("-").isdigit() or data["key"] == "-": + data["index_"] = -1 if data["key"] == "-" else int(data["key"]) + data["path"] = f"{data['nest']}[{data['index_']}]" data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".") - data["path"] = f"{data['nest']}[{data['_index']}]" return data + @computed_field # type: ignore[misc] @property def index(self) -> Union[int, str, None]: """Compute location of path. @@ -45,11 +45,11 @@ def index(self) -> Union[int, str, None]: Returns: str: path location """ - if self._index and self._index < 0: + if self.index_ and self.index_ < 0: - return f"ctx._source.{self.location}.size() - {-self._index}" + return f"ctx._source.{self.location}.size() - {-self.index_}" - return self._index + return self.index_ @computed_field # type: ignore[misc] @property diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 488437c7..bc7d943a 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -245,7 +245,7 @@ def remove_commands(commands: List[str], path: ElasticPath) -> None: """ if path.index: - commands.append(f"ctx._source.{path.location}.remove('{path.index}');") + commands.append(f"ctx._source.{path.location}.remove({path.index});") else: commands.append(f"ctx._source.{path.nest}.remove('{path.key}');") diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index a0042eb8..4217664f 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -717,8 +717,10 @@ async def test_json_patch_collection_add(ctx, core_client, txn_client): collection_id = collection["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "add", "path": "summaries.foo", "value": "bar"}, - {"op": "add", "path": "summaries.gsd.1", "value": 100}, + {"op": "add", "path": "/summaries/foo", "value": "bar"}, + ), + PatchAddReplaceTest.model_validate( + {"op": "add", "path": "/summaries/gsd/1", "value": 100}, ), ] @@ -742,7 +744,7 @@ async def test_json_patch_collection_replace(ctx, core_client, txn_client): collection_id = collection["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "summaries.gsd", "value": [100]} + {"op": "replace", "path": "/summaries/gsd", "value": [100]} ), ] @@ -756,7 +758,7 @@ async def test_json_patch_collection_replace(ctx, core_client, txn_client): collection_id, request=MockRequest ) - assert updated_collection["summaries"]["gsd"] == 100 + assert updated_collection["summaries"]["gsd"] == [100] @pytest.mark.asyncio @@ -765,7 +767,7 @@ async def test_json_patch_collection_test(ctx, core_client, txn_client): collection_id = collection["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "test", "path": "summaries.gsd", "value": 15} + {"op": "test", "path": "/summaries/gsd", "value": [30]} ), ] @@ -779,7 +781,7 @@ async def test_json_patch_collection_test(ctx, core_client, txn_client): collection_id, request=MockRequest ) - assert updated_collection["summaries"]["gsd"] == 15 + assert updated_collection["summaries"]["gsd"] == [30] @pytest.mark.asyncio @@ -788,7 +790,7 @@ async def test_json_patch_collection_move(ctx, core_client, txn_client): collection_id = collection["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "move", "path": "summaries.bar", "from": "summaries.gsd"} + {"op": "move", "path": "/summaries/bar", "from": "/summaries/gsd"} ), ] @@ -802,7 +804,7 @@ async def test_json_patch_collection_move(ctx, core_client, txn_client): collection_id, request=MockRequest ) - assert updated_collection["summaries"]["bar"] == [15] + assert updated_collection["summaries"]["bar"] == [30] assert "gsd" not in updated_collection["summaries"] @@ -812,7 +814,7 @@ async def test_json_patch_collection_copy(ctx, core_client, txn_client): collection_id = collection["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "copy", "path": "summaries.foo", "from": "summaries.gsd"} + {"op": "copy", "path": "/summaries/foo", "from": "/summaries/gsd"} ), ] @@ -836,7 +838,7 @@ async def test_json_patch_collection_remove(ctx, core_client, txn_client): collection = ctx.collection collection_id = collection["id"] operations = [ - PatchRemove.model_validate({"op": "remove", "path": "summaries.gsd"}), + PatchRemove.model_validate({"op": "remove", "path": "/summaries/gsd"}), ] await txn_client.json_patch_collection( @@ -858,7 +860,7 @@ async def test_json_patch_collection_test_wrong_value(ctx, core_client, txn_clie collection_id = collection["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "test", "path": "summaries.platform", "value": "landsat-9"} + {"op": "test", "path": "/summaries/platform", "value": "landsat-9"} ), ] @@ -879,7 +881,7 @@ async def test_json_patch_collection_replace_property_does_not_exists( collection_id = collection["id"] operations = [ PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "summaries.foo", "value": "landsat-9"} + {"op": "replace", "path": "/summaries/foo", "value": "landsat-9"} ), ] @@ -899,7 +901,7 @@ async def test_json_patch_collection_remove_property_does_not_exists( collection = ctx.collection collection_id = collection["id"] operations = [ - PatchRemove.model_validate({"op": "remove", "path": "summaries.foo"}), + PatchRemove.model_validate({"op": "remove", "path": "/summaries/foo"}), ] with pytest.raises(HTTPException): @@ -919,7 +921,7 @@ async def test_json_patch_collection_move_property_does_not_exists( collection_id = collection["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "move", "path": "summaries.bar", "from": "summaries.foo"} + {"op": "move", "path": "/summaries/bar", "from": "/summaries/foo"} ), ] @@ -940,7 +942,7 @@ async def test_json_patch_collection_copy_property_does_not_exists( collection_id = collection["id"] operations = [ PatchMoveCopy.model_validate( - {"op": "copy", "path": "summaries.bar", "from": "summaries.foo"} + {"op": "copy", "path": "/summaries/bar", "from": "/summaries/foo"} ), ] From 5c972d169305b204ff974fd7b88514ea28ce710f Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Tue, 1 Apr 2025 15:58:29 +0100 Subject: [PATCH 31/38] Consolidating add and copy commands. --- .../core/stac_fastapi/core/utilities.py | 78 ++++++++----------- .../elasticsearch/database_logic.py | 11 ++- .../tests/clients/test_elasticsearch.py | 4 +- 3 files changed, 41 insertions(+), 52 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index bc7d943a..2f4238f5 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -199,42 +199,15 @@ def check_commands( f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}" ) - -def copy_commands( - commands: List[str], - operation: PatchOperation, - path: ElasticPath, - from_path: ElasticPath, -) -> None: - """Copy value from path to from path. - - Args: - commands (List[str]): current commands - operation (PatchOperation): Operation to be converted - op_path (ElasticPath): Path to copy to - from_path (ElasticPath): Path to copy from - - """ - check_commands(commands=commands, op=operation.op, path=from_path, from_path=True) - - if from_path.index: + if from_path and path.index is not None: commands.append( - f"if ((ctx._source.{from_path.location} instanceof ArrayList" - f" && ctx._source.{from_path.location}.size() < {from_path.index})" - f" || (!ctx._source.{from_path.location}.containsKey('{from_path.index}'))" - f"{{Debug.explain('{from_path.path} does not exist');}}" + f"if ((ctx._source.{path.location} instanceof ArrayList" + f" && ctx._source.{path.location}.size() < {path.index})" + f" || (!(ctx._source.properties.hello instanceof ArrayList)" + f" && !ctx._source.{path.location}.containsKey('{path.index}')))" + f"{{Debug.explain('{path.path} does not exist');}}" ) - if path.index: - commands.append( - f"if (ctx._source.{path.location} instanceof ArrayList)" - f"{{ctx._source.{path.location}.add({path.index}, {from_path.path})}}" - f"else{{ctx._source.{path.path} = {from_path.path}}}" - ) - - else: - commands.append(f"ctx._source.{path.path} = ctx._source.{from_path.path};") - def remove_commands(commands: List[str], path: ElasticPath) -> None: """Remove value at path. @@ -244,15 +217,19 @@ def remove_commands(commands: List[str], path: ElasticPath) -> None: path (ElasticPath): Path to value to be removed """ - if path.index: - commands.append(f"ctx._source.{path.location}.remove({path.index});") + print("REMOVE PATH", path) + if path.index is not None: + commands.append(f"def temp = ctx._source.{path.location}.remove({path.index});") else: - commands.append(f"ctx._source.{path.nest}.remove('{path.key}');") + commands.append(f"def temp = ctx._source.{path.nest}.remove('{path.key}');") def add_commands( - commands: List[str], operation: PatchOperation, path: ElasticPath + commands: List[str], + operation: PatchOperation, + path: ElasticPath, + from_path: ElasticPath, ) -> None: """Add value at path. @@ -262,15 +239,20 @@ def add_commands( path (ElasticPath): path for value to be added """ - if path.index: + if from_path is not None: + value = "temp" if operation.op == "move" else f"ctx._source.{from_path.path}" + else: + value = operation.json_value + + if path.index is not None: commands.append( f"if (ctx._source.{path.location} instanceof ArrayList)" - f"{{ctx._source.{path.location}.add({path.index}, {operation.json_value})}}" - f"else{{ctx._source.{path.path} = {operation.json_value}}}" + f"{{ctx._source.{path.location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}" + f"else{{ctx._source.{path.path} = {value}}}" ) else: - commands.append(f"ctx._source.{path.path} = {operation.json_value};") + commands.append(f"ctx._source.{path.path} = {value};") def test_commands( @@ -335,24 +317,26 @@ def operations_to_script(operations: List) -> Dict: ) check_commands(commands=commands, op=operation.op, path=path) - - if operation.op in ["copy", "move"]: - copy_commands( - commands=commands, operation=operation, path=path, from_path=from_path + if from_path is not None: + check_commands( + commands=commands, op=operation.op, path=from_path, from_path=True ) if operation.op in ["remove", "move"]: remove_path = from_path if from_path else path remove_commands(commands=commands, path=remove_path) - if operation.op in ["add", "replace"]: - add_commands(commands=commands, operation=operation, path=path) + if operation.op in ["add", "replace", "copy", "move"]: + add_commands( + commands=commands, operation=operation, path=path, from_path=from_path + ) if operation.op == "test": test_commands(commands=commands, operation=operation, path=path) source = commands_to_source(commands=commands) + print("____SOURCE", source) return { "source": source, "lang": "painless", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 3c4e4e17..d41a4fe3 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -988,9 +988,13 @@ async def json_patch_item( ) except exceptions.BadRequestError as exc: - raise HTTPException( - status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] - ) from exc + detail = ( + exc.info["error"]["caused_by"]["to_string"] + if "to_string" in exc.info["error"]["caused_by"] + else exc.info["error"]["caused_by"] + ) + print("____________EXC INFO", exc.info) + raise HTTPException(status_code=400, detail=detail) from exc item = await self.get_one_item(collection_id, item_id) @@ -1225,6 +1229,7 @@ async def json_patch_collection( ) except exceptions.BadRequestError as exc: + print("EXC", exc.info) raise HTTPException( status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] ) from exc diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 4217664f..2b39f29b 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -307,7 +307,7 @@ async def test_json_patch_item_add(ctx, core_client, txn_client): assert updated_item["properties"]["foo"] == "bar" assert updated_item["properties"]["ext:hello"] == "world" - assert updated_item["properties"]["area"] == [2500, -100, 10] + assert updated_item["properties"]["area"] == [2500, 10, -200] @pytest.mark.asyncio @@ -735,7 +735,7 @@ async def test_json_patch_collection_add(ctx, core_client, txn_client): ) assert updated_collection["summaries"]["foo"] == "bar" - assert updated_collection["summaries"]["gsd"] == [15, 100] + assert updated_collection["summaries"]["gsd"] == [30, 100] @pytest.mark.asyncio From b532058b3c3fe3bb30083123b5b401e461cd409a Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Tue, 1 Apr 2025 15:59:41 +0100 Subject: [PATCH 32/38] remove debug prints. --- stac_fastapi/core/stac_fastapi/core/utilities.py | 2 -- .../stac_fastapi/elasticsearch/database_logic.py | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index 2f4238f5..c7b389f8 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -217,7 +217,6 @@ def remove_commands(commands: List[str], path: ElasticPath) -> None: path (ElasticPath): Path to value to be removed """ - print("REMOVE PATH", path) if path.index is not None: commands.append(f"def temp = ctx._source.{path.location}.remove({path.index});") @@ -336,7 +335,6 @@ def operations_to_script(operations: List) -> Dict: source = commands_to_source(commands=commands) - print("____SOURCE", source) return { "source": source, "lang": "painless", diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index d41a4fe3..570570bd 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -993,7 +993,6 @@ async def json_patch_item( if "to_string" in exc.info["error"]["caused_by"] else exc.info["error"]["caused_by"] ) - print("____________EXC INFO", exc.info) raise HTTPException(status_code=400, detail=detail) from exc item = await self.get_one_item(collection_id, item_id) @@ -1229,10 +1228,12 @@ async def json_patch_collection( ) except exceptions.BadRequestError as exc: - print("EXC", exc.info) - raise HTTPException( - status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] - ) from exc + detail = ( + exc.info["error"]["caused_by"]["to_string"] + if "to_string" in exc.info["error"]["caused_by"] + else exc.info["error"]["caused_by"] + ) + raise HTTPException(status_code=400, detail=detail) from exc collection = await self.find_collection(collection_id) From 3ffe9d5dbdc286f60c90f9b8d256779396a8f611 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 2 Apr 2025 11:12:20 +0100 Subject: [PATCH 33/38] Move extension replacement to ElasticPath. --- .../core/stac_fastapi/core/models/patch.py | 93 ++++++++++++++++++- .../core/stac_fastapi/core/utilities.py | 68 +++++++------- .../tests/clients/test_elasticsearch.py | 19 ++-- 3 files changed, 136 insertions(+), 44 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index ae300316..c39d2e67 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -1,9 +1,62 @@ """patch helpers.""" -from typing import Any, Optional, Union +import re +from typing import Any, Dict, Optional, Union from pydantic import BaseModel, computed_field, model_validator +regex = re.compile(r"([^.' ]*:[^.' ]*)\.?") + + +class ESCommandSet: + """Uses dictionary keys to behaviour of ordered set. + + Yields: + str: Elasticsearch commands + """ + + dict_: Dict[str, None] = {} + + def add(self, value: str): + """Add command. + + Args: + value (str): value to be added + """ + self.dict_[value] = None + + def remove(self, value: str): + """Remove command. + + Args: + value (str): value to be removed + """ + del self.dict_[value] + + def __iter__(self): + """Iterate Elasticsearch commands. + + Yields: + str: Elasticsearch command + """ + yield from self.dict_.keys() + + +def to_es(string: str): + """Convert patch operation key to Elasticsearch key. + + Args: + string (str): string to be converted + + Returns: + _type_: converted string + """ + if matches := regex.findall(string): + for match in set(matches): + string = re.sub(rf"\.?{match}", f"['{match}']", string) + + return string + class ElasticPath(BaseModel): """Converts a JSON path to an Elasticsearch path. @@ -17,6 +70,11 @@ class ElasticPath(BaseModel): nest: Optional[str] = None partition: Optional[str] = None key: Optional[str] = None + + es_path: Optional[str] = None + es_nest: Optional[str] = None + es_key: Optional[str] = None + index_: Optional[int] = None @model_validator(mode="before") @@ -35,6 +93,10 @@ def validate_model(cls, data: Any): data["path"] = f"{data['nest']}[{data['index_']}]" data["nest"], data["partition"], data["key"] = data["nest"].rpartition(".") + data["es_path"] = to_es(data["path"]) + data["es_nest"] = to_es(data["nest"]) + data["es_key"] = to_es(data["key"]) + return data @computed_field # type: ignore[misc] @@ -43,7 +105,7 @@ def index(self) -> Union[int, str, None]: """Compute location of path. Returns: - str: path location + str: path index """ if self.index_ and self.index_ < 0: @@ -60,3 +122,30 @@ def location(self) -> str: str: path location """ return self.nest + self.partition + self.key + + @computed_field # type: ignore[misc] + @property + def es_location(self) -> str: + """Compute location of path. + + Returns: + str: path location + """ + if self.es_key and ":" in self.es_key: + return self.es_nest + self.es_key + return self.es_nest + self.partition + self.es_key + + @computed_field # type: ignore[misc] + @property + def variable_name(self) -> str: + """Variable name for scripting. + + Returns: + str: variable name + """ + if self.index is not None: + return f"{self.location.replace('.','_').replace(':','_')}_{self.index}" + + return ( + f"{self.nest.replace('.','_').replace(':','_')}_{self.key.replace(':','_')}" + ) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index c7b389f8..f00812ab 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -4,10 +4,9 @@ such as converting bounding boxes to polygon representations. """ -import re from typing import Any, Dict, List, Optional, Set, Union -from stac_fastapi.core.models.patch import ElasticPath +from stac_fastapi.core.models.patch import ElasticPath, ESCommandSet from stac_fastapi.types.stac import ( Item, PatchAddReplaceTest, @@ -173,7 +172,7 @@ def merge_to_operations(data: Dict) -> List: def check_commands( - commands: List[str], + commands: ESCommandSet, op: str, path: ElasticPath, from_path: bool = False, @@ -188,28 +187,28 @@ def check_commands( """ if path.nest: - commands.append( + commands.add( f"if (!ctx._source.containsKey('{path.nest}'))" f"{{Debug.explain('{path.nest} does not exist');}}" ) if path.index or op in ["remove", "replace", "test"] or from_path: - commands.append( - f"if (!ctx._source.{path.nest}.containsKey('{path.key}'))" + commands.add( + f"if (!ctx._source.{path.es_nest}.containsKey('{path.key}'))" f"{{Debug.explain('{path.key} does not exist in {path.nest}');}}" ) if from_path and path.index is not None: - commands.append( - f"if ((ctx._source.{path.location} instanceof ArrayList" - f" && ctx._source.{path.location}.size() < {path.index})" + commands.add( + f"if ((ctx._source.{path.es_location} instanceof ArrayList" + f" && ctx._source.{path.es_location}.size() < {path.index})" f" || (!(ctx._source.properties.hello instanceof ArrayList)" - f" && !ctx._source.{path.location}.containsKey('{path.index}')))" + f" && !ctx._source.{path.es_location}.containsKey('{path.index}')))" f"{{Debug.explain('{path.path} does not exist');}}" ) -def remove_commands(commands: List[str], path: ElasticPath) -> None: +def remove_commands(commands: ESCommandSet, path: ElasticPath) -> None: """Remove value at path. Args: @@ -218,14 +217,18 @@ def remove_commands(commands: List[str], path: ElasticPath) -> None: """ if path.index is not None: - commands.append(f"def temp = ctx._source.{path.location}.remove({path.index});") + commands.add( + f"def {path.variable_name} = ctx._source.{path.es_location}.remove({path.index});" + ) else: - commands.append(f"def temp = ctx._source.{path.nest}.remove('{path.key}');") + commands.add( + f"def {path.variable_name} = ctx._source.{path.es_nest}.remove('{path.key}');" + ) def add_commands( - commands: List[str], + commands: ESCommandSet, operation: PatchOperation, path: ElasticPath, from_path: ElasticPath, @@ -239,23 +242,27 @@ def add_commands( """ if from_path is not None: - value = "temp" if operation.op == "move" else f"ctx._source.{from_path.path}" + value = ( + from_path.variable_name + if operation.op == "move" + else f"ctx._source.{from_path.es_path}" + ) else: value = operation.json_value if path.index is not None: - commands.append( - f"if (ctx._source.{path.location} instanceof ArrayList)" - f"{{ctx._source.{path.location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}" - f"else{{ctx._source.{path.path} = {value}}}" + commands.add( + f"if (ctx._source.{path.es_location} instanceof ArrayList)" + f"{{ctx._source.{path.es_location}.{'add' if operation.op in ['add', 'move'] else 'set'}({path.index}, {value})}}" + f"else{{ctx._source.{path.es_path} = {value}}}" ) else: - commands.append(f"ctx._source.{path.path} = {value};") + commands.add(f"ctx._source.{path.es_path} = {value};") def test_commands( - commands: List[str], operation: PatchOperation, path: ElasticPath + commands: ESCommandSet, operation: PatchOperation, path: ElasticPath ) -> None: """Test value at path. @@ -264,10 +271,10 @@ def test_commands( operation (PatchOperation): operation to run path (ElasticPath): path for value to be tested """ - commands.append( - f"if (ctx._source.{path.location} != {operation.json_value})" - f"{{Debug.explain('Test failed for: {path.path} | " - f"{operation.json_value} != ' + ctx._source.{path.location});}}" + commands.add( + f"if (ctx._source.{path.es_path} != {operation.json_value})" + f"{{Debug.explain('Test failed `{path.path}` | " + f"{operation.json_value} != ' + ctx._source.{path.es_path});}}" ) @@ -282,18 +289,13 @@ def commands_to_source(commands: List[str]) -> str: """ seen: Set[str] = set() seen_add = seen.add - regex = re.compile(r"([^.' ]*:[^.' ]*)[. ]") + # regex = re.compile(r"([^.' ]*:[^.' ]*)[. ;]") source = "" # filter duplicate lines for command in commands: if command not in seen: seen_add(command) - # extension terms with using `:` must be swapped out - if matches := regex.findall(command): - for match in matches: - command = command.replace(f".{match}", f"['{match}']") - source += command return source @@ -308,7 +310,7 @@ def operations_to_script(operations: List) -> Dict: Returns: Dict: elasticsearch update script. """ - commands: List = [] + commands: ESCommandSet = ESCommandSet() for operation in operations: path = ElasticPath(path=operation.path) from_path = ( @@ -333,7 +335,7 @@ def operations_to_script(operations: List) -> Dict: if operation.op == "test": test_commands(commands=commands, operation=operation, path=path) - source = commands_to_source(commands=commands) + source = "".join(commands) return { "source": source, diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index 2b39f29b..cb82ff2e 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -323,7 +323,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): {"op": "replace", "path": "/properties/proj:epsg", "value": "world"} ), PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "/properties/area/1", "value": "50"} + {"op": "replace", "path": "/properties/area/1", "value": 50} ), ] @@ -339,7 +339,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): ) assert updated_item["properties"]["gsd"] == 100 - assert updated_item["properties"]["proj:epsg"] == 100 + assert updated_item["properties"]["proj:epsg"] == "world" assert updated_item["properties"]["area"] == [2500, 50] @@ -356,7 +356,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): {"op": "test", "path": "/properties/proj:epsg", "value": 32756} ), PatchAddReplaceTest.model_validate( - {"op": "test", "path": "/properties/area/1", "value": -100} + {"op": "test", "path": "/properties/area/1", "value": -200} ), ] @@ -373,7 +373,7 @@ async def test_json_patch_item_test(ctx, core_client, txn_client): assert updated_item["properties"]["gsd"] == 15 assert updated_item["properties"]["proj:epsg"] == 32756 - assert updated_item["properties"]["area"][1] == -100 + assert updated_item["properties"]["area"][1] == -200 @pytest.mark.asyncio @@ -389,7 +389,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): {"op": "move", "path": "/properties/bar", "from": "/properties/proj:epsg"} ), PatchMoveCopy.model_validate( - {"op": "move", "path": "/properties/hello", "from": "/properties/area/1"} + {"op": "move", "path": "/properties/area/0", "from": "/properties/area/1"} ), ] @@ -408,8 +408,7 @@ async def test_json_patch_item_move(ctx, core_client, txn_client): assert "gsd" not in updated_item["properties"] assert updated_item["properties"]["bar"] == 32756 assert "proj:epsg" not in updated_item["properties"] - assert updated_item["properties"]["hello"] == [-100] - assert updated_item["properties"]["area"] == [2500] + assert updated_item["properties"]["area"] == [-200, 2500] @pytest.mark.asyncio @@ -425,7 +424,7 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): {"op": "copy", "path": "/properties/bar", "from": "/properties/proj:epsg"} ), PatchMoveCopy.model_validate( - {"op": "copy", "path": "/properties/hello", "from": "/properties/area/1"} + {"op": "copy", "path": "/properties/area/0", "from": "/properties/area/1"} ), ] @@ -442,7 +441,9 @@ async def test_json_patch_item_copy(ctx, core_client, txn_client): assert updated_item["properties"]["foo"] == updated_item["properties"]["gsd"] assert updated_item["properties"]["bar"] == updated_item["properties"]["proj:epsg"] - assert updated_item["properties"]["hello"] == updated_item["properties"]["area"][1] + assert ( + updated_item["properties"]["area"][0] == updated_item["properties"]["area"][1] + ) @pytest.mark.asyncio From 0822417dd33357f4d4ac3884034fe8245d4d0e9b Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 2 Apr 2025 11:36:28 +0100 Subject: [PATCH 34/38] Reset command set between different patches. --- .../core/stac_fastapi/core/models/patch.py | 4 ++++ .../core/stac_fastapi/core/utilities.py | 23 ------------------- .../tests/clients/test_elasticsearch.py | 4 ++-- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/stac_fastapi/core/stac_fastapi/core/models/patch.py b/stac_fastapi/core/stac_fastapi/core/models/patch.py index c39d2e67..7dbfd3db 100644 --- a/stac_fastapi/core/stac_fastapi/core/models/patch.py +++ b/stac_fastapi/core/stac_fastapi/core/models/patch.py @@ -17,6 +17,10 @@ class ESCommandSet: dict_: Dict[str, None] = {} + def __init__(self): + """Initialise ESCommandSet instance.""" + self.dict_ = {} + def add(self, value: str): """Add command. diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index f00812ab..eb26aa9b 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -278,29 +278,6 @@ def test_commands( ) -def commands_to_source(commands: List[str]) -> str: - """Convert list of commands to Elasticsearch script source. - - Args: - commands (List[str]): List of Elasticearch commands - - Returns: - str: Elasticsearch script source - """ - seen: Set[str] = set() - seen_add = seen.add - # regex = re.compile(r"([^.' ]*:[^.' ]*)[. ;]") - source = "" - - # filter duplicate lines - for command in commands: - if command not in seen: - seen_add(command) - source += command - - return source - - def operations_to_script(operations: List) -> Dict: """Convert list of operation to painless script. diff --git a/stac_fastapi/tests/clients/test_elasticsearch.py b/stac_fastapi/tests/clients/test_elasticsearch.py index cb82ff2e..8d923e0a 100644 --- a/stac_fastapi/tests/clients/test_elasticsearch.py +++ b/stac_fastapi/tests/clients/test_elasticsearch.py @@ -320,7 +320,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): {"op": "replace", "path": "/properties/gsd", "value": 100} ), PatchAddReplaceTest.model_validate( - {"op": "replace", "path": "/properties/proj:epsg", "value": "world"} + {"op": "replace", "path": "/properties/proj:epsg", "value": 12345} ), PatchAddReplaceTest.model_validate( {"op": "replace", "path": "/properties/area/1", "value": 50} @@ -339,7 +339,7 @@ async def test_json_patch_item_replace(ctx, core_client, txn_client): ) assert updated_item["properties"]["gsd"] == 100 - assert updated_item["properties"]["proj:epsg"] == "world" + assert updated_item["properties"]["proj:epsg"] == 12345 assert updated_item["properties"]["area"] == [2500, 50] From 050254f8de86ab91a0dc1c67ac29ead208a5e207 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 2 Apr 2025 12:02:38 +0100 Subject: [PATCH 35/38] Correcting bad check command. --- stac_fastapi/core/stac_fastapi/core/utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/core/stac_fastapi/core/utilities.py b/stac_fastapi/core/stac_fastapi/core/utilities.py index eb26aa9b..c1e98103 100644 --- a/stac_fastapi/core/stac_fastapi/core/utilities.py +++ b/stac_fastapi/core/stac_fastapi/core/utilities.py @@ -202,7 +202,7 @@ def check_commands( commands.add( f"if ((ctx._source.{path.es_location} instanceof ArrayList" f" && ctx._source.{path.es_location}.size() < {path.index})" - f" || (!(ctx._source.properties.hello instanceof ArrayList)" + f" || (!(ctx._source.{path.es_location} instanceof ArrayList)" f" && !ctx._source.{path.es_location}.containsKey('{path.index}')))" f"{{Debug.explain('{path.path} does not exist');}}" ) From b5544dda65ef7215dcee68e47f0b20837fe704c2 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 2 Apr 2025 12:11:14 +0100 Subject: [PATCH 36/38] Update opensearch collection patch. --- .../elasticsearch/database_logic.py | 18 ++++++------------ .../stac_fastapi/opensearch/database_logic.py | 11 ++++++----- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py index 570570bd..122dff22 100644 --- a/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py +++ b/stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py @@ -988,12 +988,9 @@ async def json_patch_item( ) except exceptions.BadRequestError as exc: - detail = ( - exc.info["error"]["caused_by"]["to_string"] - if "to_string" in exc.info["error"]["caused_by"] - else exc.info["error"]["caused_by"] - ) - raise HTTPException(status_code=400, detail=detail) from exc + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"] + ) from exc item = await self.get_one_item(collection_id, item_id) @@ -1228,12 +1225,9 @@ async def json_patch_collection( ) except exceptions.BadRequestError as exc: - detail = ( - exc.info["error"]["caused_by"]["to_string"] - if "to_string" in exc.info["error"]["caused_by"] - else exc.info["error"]["caused_by"] - ) - raise HTTPException(status_code=400, detail=detail) from exc + raise HTTPException( + status_code=400, detail=exc.info["error"]["caused_by"] + ) from exc collection = await self.find_collection(collection_id) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 7fe9bcaf..86634454 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1021,7 +1021,7 @@ async def json_patch_item( except exceptions.RequestError as exc: raise HTTPException( - status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] + status_code=400, detail=exc.info["error"]["caused_by"] ) from exc item = await self.get_one_item(collection_id, item_id) @@ -1237,8 +1237,8 @@ async def json_patch_collection( for operation in operations: if ( - operation["op"] in ["add", "replace"] - and operation["path"] == "collection" + operation.op in ["add", "replace"] + and operation.path == "collection" and collection_id != operation["value"] ): new_collection_id = operation["value"] @@ -1252,13 +1252,14 @@ async def json_patch_collection( await self.client.update( index=COLLECTIONS_INDEX, id=collection_id, - script=script, + body={"script": script}, refresh=True, ) except exceptions.BadRequestError as exc: + raise HTTPException( - status_code=400, detail=exc.info["error"]["caused_by"]["to_string"] + status_code=400, detail=exc.info["error"]["caused_by"] ) from exc collection = await self.find_collection(collection_id) From 5887227e6dbb9b3809d2c06cb4049bb96a36b8c5 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 2 Apr 2025 12:18:21 +0100 Subject: [PATCH 37/38] RequestError not BadRequestError for opensearch. --- .../opensearch/stac_fastapi/opensearch/database_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py index 86634454..3a2234df 100644 --- a/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py +++ b/stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py @@ -1256,7 +1256,7 @@ async def json_patch_collection( refresh=True, ) - except exceptions.BadRequestError as exc: + except exceptions.RequestError as exc: raise HTTPException( status_code=400, detail=exc.info["error"]["caused_by"] From 2b1381821ec978ea254df0c8fae87420186a8789 Mon Sep 17 00:00:00 2001 From: rhysrevans3 Date: Wed, 2 Apr 2025 12:30:03 +0100 Subject: [PATCH 38/38] Remove python 3.8 support. --- .github/workflows/cicd.yml | 2 +- .github/workflows/deploy_mkdocs.yml | 4 ++-- dockerfiles/Dockerfile.docs | 2 +- stac_fastapi/core/setup.py | 3 +-- stac_fastapi/elasticsearch/setup.py | 3 +-- stac_fastapi/opensearch/setup.py | 3 +-- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a966248b..864b52e3 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -65,7 +65,7 @@ jobs: strategy: matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"] backend: [ "elasticsearch7", "elasticsearch8", "opensearch"] name: Python ${{ matrix.python-version }} testing with ${{ matrix.backend }} diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index 833c1021..3606d654 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -20,10 +20,10 @@ jobs: - name: Checkout main uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | diff --git a/dockerfiles/Dockerfile.docs b/dockerfiles/Dockerfile.docs index f1fe63b8..937ae014 100644 --- a/dockerfiles/Dockerfile.docs +++ b/dockerfiles/Dockerfile.docs @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.9-slim # build-essential is required to build a wheel for ciso8601 RUN apt update && apt install -y build-essential diff --git a/stac_fastapi/core/setup.py b/stac_fastapi/core/setup.py index 6168625a..4d6ec2b3 100644 --- a/stac_fastapi/core/setup.py +++ b/stac_fastapi/core/setup.py @@ -26,12 +26,11 @@ description="Core library for the Elasticsearch and Opensearch stac-fastapi backends.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/stac_fastapi/elasticsearch/setup.py b/stac_fastapi/elasticsearch/setup.py index 7cc31566..a527e60f 100644 --- a/stac_fastapi/elasticsearch/setup.py +++ b/stac_fastapi/elasticsearch/setup.py @@ -32,12 +32,11 @@ description="An implementation of STAC API based on the FastAPI framework with both Elasticsearch and Opensearch.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/stac_fastapi/opensearch/setup.py b/stac_fastapi/opensearch/setup.py index 8522e456..5d583d07 100644 --- a/stac_fastapi/opensearch/setup.py +++ b/stac_fastapi/opensearch/setup.py @@ -32,12 +32,11 @@ description="Opensearch stac-fastapi backend.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11",