diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 24e2c1204e6f697d6373e088cadb3165a994f5c5..bdb8804917beb7397f8bc78da33479b259a53e0c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,13 +23,29 @@ stages: - Documentation - Pip -Test API: +.static_analysis_base: + allow_failure: false + +Test Upload: + extends: .static_analysis_with_pip_install + stage: Test + except: [main] + script: + - python tests/test_upload.py + - DINAMIS_SDK_ACCESS_KEY=${CI_VAR_DINAMIS_SDK_ACCESS_KEY} + DINAMIS_SDK_SECRET_KEY=${CI_VAR_DINAMIS_SDK_SECRET_KEY} + python tests/test_upload.py + +Test Get: + extends: .static_analysis_with_pip_install + stage: Test + except: [main] + script: + - python tests/test_get.py + +Test Diff: extends: .static_analysis_with_pip_install stage: Test - allow_failure: false except: [main] script: - - python tests/all.py - - DINAMIS_SDK_ACCESS_KEY=${CI_VAR_DINAMIS_SDK_ACCESS_KEY} - DINAMIS_SDK_SECRET_KEY=${CI_VAR_DINAMIS_SDK_SECRET_KEY} - python tests/all.py + - python tests/test_diff.py diff --git a/README.md b/README.md index 18b4aebdaca6778403e1e92b4739f60b01448fda..dd6e7a8fd6d233bb57e26f7391cae6379868ba57 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ </p> **Theia-dumper** enables to upload Spatio Temporal Assets Catalogs (STAC) on the -THEIA-MTP geospatial data center. +THEIA-MTD geospatial data center. For more information read the [documentation](https://cdos-pub.pages.mia.inra.fr/theia-dumper). diff --git a/pyproject.toml b/pyproject.toml index 1082b9b1659cd7a4b3a1cc2e4c279c3aa0d7c426..05c6645e83b9da64c6e571b8803c30138e3e4e25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "theia_dumper" -version = "0.0.5" description = "THEIA-MTP geospatial data publisher" +dynamic = ["version"] authors = [{ name = "Rémi Cresson", email = "remi.cresson@inrae.fr" }] requires-python = ">=3.9" dependencies = [ "setuptools", "pystac", "pystac_client", - "dinamis_sdk>=0.4.0", + "dinamis_sdk>=0.4.1", "requests", + "click", + "rich", "rio-cogeo", ] license = { text = "Apache-2.0" } @@ -42,7 +44,7 @@ pretty = true exclude = ["doc", "venv", ".venv"] [tool.pylint] -disable = "W1203,R0903,E0401,W0622,C0116,C0115" +disable = "W1203,R0903,E0401,W0622,C0116,C0115,W0719" [tool.pylint.MASTER] ignore-paths = '^.venv' diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100755 index 0000000000000000000000000000000000000000..3e0aebb0d04109fa25a1a2a7e6b3372c07643e6d --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,33 @@ +"""Test file.""" + +import test_upload +import pystac + +from theia_dumper import stac, diff + + +col1, items = test_upload.create_items_and_collection( + relative=True, col_href="/tmp/collection.json" +) +col2 = col1.full_copy() + +item = items[0].full_copy() +item.id += "_test" +col2.add_item(item, item.id) + +item = items[0].full_copy() +item.id += "_test_other" +col1.add_item(item, item.id) + +diff.generate_items_diff(col1, col2) +diff.collections_defs_are_different(col1, col2) + +COL1_FILEPATH = "/tmp/col1.json" +col1.set_self_href(COL1_FILEPATH) +col1.save(catalog_type=pystac.CatalogType.RELATIVE_PUBLISHED) + +diff.compare_local_and_upstream( + stac.StacTransactionsHandler(stac.DEFAULT_STAC_EP), + COL1_FILEPATH, + "costarica-sentinel-2-l3-seasonal-spectral-indices-M", +) diff --git a/tests/test_get.py b/tests/test_get.py new file mode 100755 index 0000000000000000000000000000000000000000..652169c1b0ae095eaf0cb447aca561ff3a1aba74 --- /dev/null +++ b/tests/test_get.py @@ -0,0 +1,16 @@ +"""Test file.""" + +from theia_dumper import stac, cli + + +handler = stac.StacTransactionsHandler( + stac_endpoint=cli.DEFAULT_STAC_EP, +) + +REMOTE_COL_ID = "spot-6-7-drs" +handler.list_collections_display() +handler.list_col_items_display(REMOTE_COL_ID) + +col_remote = handler.get_remote_col(REMOTE_COL_ID) + +col_items = handler.list_col_items(REMOTE_COL_ID) diff --git a/tests/all.py b/tests/test_upload.py similarity index 81% rename from tests/all.py rename to tests/test_upload.py index 9143fb0e0a43aa0e1da2c66ed7d3fe97a91a928e..5355e784f7060e0249a5d969eca178e37f103d34 100755 --- a/tests/all.py +++ b/tests/test_upload.py @@ -12,32 +12,30 @@ import requests from theia_dumper import stac +DEFAULT_COL_HREF = "http://hello.fr/collections/collection-for-tests" STAC_EP = "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr" - - -handler = stac.TransactionsHandler( - stac_endpoint=STAC_EP, - storage_endpoint="https://s3-data.meso.umontpellier.fr", - storage_bucket="sm1-gdc-tests", - assets_overwrite=True, -) - IMAGE_HREF = ( "https://gitlab.orfeo-toolbox.org/orfeotoolbox/" "otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif" ) - COL_ID = "collection-for-theia-dumper-tests" items_ids = ["item_1", "item_2"] - RASTER_FILE1 = "/tmp/raster1.tif" RASTER_FILE2 = "/tmp/raster2.tif" + +handler = stac.StacUploadTransactionsHandler( + stac_endpoint=STAC_EP, + storage_endpoint="https://s3-data.meso.umontpellier.fr", + storage_bucket="sm1-gdc-tests", + assets_overwrite=True, +) + with open(RASTER_FILE1, "wb") as f: r = requests.get(IMAGE_HREF, timeout=5) f.write(r.content) shutil.copyfile(RASTER_FILE1, RASTER_FILE2) -COL_BBOX = [0, 0, 0, 0] +COL_BBOX = [0.0, 0.0, 0.0, 0.0] BBOX_ALL = [ 3.6962018175925073, 43.547450099338604, @@ -57,20 +55,26 @@ COORDS2 = [[coord + 5 for coord in coords] for coords in COORDS1] def clear(): """Clear all test items and collection.""" for item_id in items_ids: - handler.delete(col_id=COL_ID, item_id=item_id) - handler.delete(col_id=COL_ID) + handler.delete_item_or_col(col_id=COL_ID, item_id=item_id) + handler.delete_item_or_col(col_id=COL_ID) -def check(expected_bbox): - """Check collection extent.""" +def remote_col_test(expected_bbox): + """Run tests on a remote collection.""" api = pystac_client.Client.open(STAC_EP) - extent = api.get_collection(COL_ID).extent.spatial.bboxes - print(f"extent.spatial: {extent}") + col = api.get_collection(COL_ID) + extent = col.extent.spatial.bboxes assert len(extent) == 1 assert tuple(extent[0]) == tuple(expected_bbox), ( f"expected BBOX: {expected_bbox}, got {extent[0]}" ) + # Check that assets are accessible once signed + for i in col.get_items(): + assets = i.get_assets().values() + for asset in assets: + assert stac.asset_exists(asset.href) + def create_item(item_id: str): """Create a STAC item.""" @@ -98,15 +102,15 @@ def create_item(item_id: str): return item -def create_collection(): +def create_collection(col_href: str): """Create an empty STAC collection.""" spat_extent = pystac.SpatialExtent([COL_BBOX]) - temp_extent = pystac.TemporalExtent(intervals=[(None, None)]) + temp_extent = pystac.TemporalExtent(intervals=[[None, None]]) # type: ignore col = pystac.Collection( id=COL_ID, extent=pystac.Extent(spat_extent, temp_extent), description="Some description", - href="http://hello.fr/collections/collection-for-tests", + href=col_href, providers=[ pystac.Provider("INRAE"), ], @@ -114,13 +118,13 @@ def create_collection(): return col -def create_items_and_collection(relative, items=None): +def create_items_and_collection(relative, items=None, col_href=DEFAULT_COL_HREF): """Create two STAC items attached to one collection.""" # Create items items = items or [create_item(item_id=item_id) for item_id in items_ids] # Attach items to collection - col = create_collection() + col = create_collection(col_href) for item in items: col.add_item(item) if relative: @@ -155,13 +159,13 @@ def test_item_collection(): print(f"Relative: {relative}") # we need to create an empty collection before - col = create_collection() + col = create_collection(DEFAULT_COL_HREF) handler.publish_collection(collection=col) with tempfile.NamedTemporaryFile() as tmp: generate_item_collection(tmp.name, relative=relative) handler.load_and_publish(tmp.name) - check(BBOX_ALL) + remote_col_test(BBOX_ALL) clear() @@ -172,7 +176,7 @@ def test_collection(): with tempfile.TemporaryDirectory() as tmpdir: generate_collection(tmpdir, relative=relative) handler.load_and_publish(os.path.join(tmpdir, "collection.json")) - check(BBOX_ALL) + remote_col_test(BBOX_ALL) clear() @@ -186,20 +190,17 @@ def test_collection_multipart(): tmpdir, relative=relative, items=[create_item(item_id)] ) handler.load_and_publish(os.path.join(tmpdir, "collection.json")) - check(BBOX_ALL) + remote_col_test(BBOX_ALL) clear() -def test_all(): - """Test all.""" - # test collection +def _test_all(): test_collection() - # test item collection test_item_collection() - # test collection (multi-part) test_collection_multipart() -test_all() +if __name__ == "__main__": + _test_all() diff --git a/theia_dumper/__init__.py b/theia_dumper/__init__.py index 03970df13c1d94d863e594d033f6c21c77839c14..ec8539461cdb7667033272a0675ef109ae8da6c9 100644 --- a/theia_dumper/__init__.py +++ b/theia_dumper/__init__.py @@ -1 +1,3 @@ """Theia dumper package.""" + +__version__ = "0.1.0" diff --git a/theia_dumper/cli.py b/theia_dumper/cli.py index 8f6f70d2939ebdcc1cc9cfd5a00012e544a38ac6..1627dca554e23093acaf6dc3d4621969c817fcfa 100644 --- a/theia_dumper/cli.py +++ b/theia_dumper/cli.py @@ -1,10 +1,13 @@ """Theia-dumper Command Line Interface.""" import click -from .stac import TransactionsHandler, delete_stac_obj - - -DEFAULT_STAC_EP = "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr" +from .stac import ( + StacUploadTransactionsHandler, + StacTransactionsHandler, + DEFAULT_S3_EP, + DEFAULT_STAC_EP, +) +from . import diff @click.group() @@ -24,7 +27,7 @@ def theia_dumper() -> None: "--storage_endpoint", type=str, help="Storage endpoint assets will be sent to", - default="https://s3-data.meso.umontpellier.fr", + default=DEFAULT_S3_EP, ) @click.option( "-b", @@ -48,13 +51,12 @@ def publish( overwrite: bool, ): """Publish a STAC object (collection or item collection).""" - handler = TransactionsHandler( + StacUploadTransactionsHandler( stac_endpoint=stac_endpoint, storage_endpoint=storage_endpoint, storage_bucket=storage_bucket, assets_overwrite=overwrite, - ) - handler.load_and_publish(stac_obj_path) + ).load_and_publish(stac_obj_path) @theia_dumper.command(context_settings={"show_default": True}) @@ -71,5 +73,72 @@ def delete( col_id: str, item_id: str, ): - """Publish a STAC object (collection or item collection).""" - delete_stac_obj(stac_endpoint=stac_endpoint, col_id=col_id, item_id=item_id) + """Delete a STAC object (collection or item).""" + StacTransactionsHandler( + stac_endpoint=stac_endpoint, + ).delete_item_or_col(col_id=col_id, item_id=item_id) + + +@theia_dumper.command(context_settings={"show_default": True}) +@click.option( + "--stac_endpoint", + help="Endpoint to which STAC objects will be sent", + type=str, + default=DEFAULT_STAC_EP, +) +def list_cols( + stac_endpoint: str, +): + """List collections.""" + StacTransactionsHandler( + stac_endpoint=stac_endpoint, + ).list_collections_display() + + +@theia_dumper.command(context_settings={"show_default": True}) +@click.option( + "--stac_endpoint", + help="Endpoint to which STAC objects will be sent", + type=str, + default=DEFAULT_STAC_EP, +) +@click.option("-c", "--col_id", type=str, help="STAC collection ID", required=True) +@click.option( + "-m", "--max_items", type=int, help="Max number of items to display", default=20 +) +def list_col_items( + stac_endpoint: str, + col_id: str, + max_items: int, +): + """List collection items.""" + StacTransactionsHandler( + stac_endpoint=stac_endpoint, + ).list_col_items_display(col_id=col_id, max_items=max_items) + + +@theia_dumper.command(context_settings={"show_default": True}) +@click.option( + "--stac_endpoint", + help="Endpoint to which STAC objects will be sent", + type=str, + default=DEFAULT_STAC_EP, +) +@click.option("-p", "--col_path", type=str, help="Local collection path", required=True) +@click.option( + "-r", + "--remote_id", + type=str, + help="Remote collection ID. If not specified, will use local collection ID", + required=False, +) +def collection_diff( + stac_endpoint: str, + col_path: str, + remote_id: str | None = None, +): + """List collection items.""" + handler = StacTransactionsHandler( + stac_endpoint=stac_endpoint, + ) + diff.compare_local_and_upstream(handler, col_path, remote_id) diff --git a/theia_dumper/diff.py b/theia_dumper/diff.py new file mode 100644 index 0000000000000000000000000000000000000000..52032ee524b50657f9e17ecc48764c8a37dc2c6c --- /dev/null +++ b/theia_dumper/diff.py @@ -0,0 +1,102 @@ +"""STAC diff tool.""" + +from typing import Tuple, List, cast +from pystac import Collection, Item +from rich import print +from .logger import logger + +from . import stac + +UNIQUE_SEP = "___" + + +def collections_defs_are_different(col1: Collection, col2: Collection) -> bool: + """Compute the diff between 2 STAC collections.""" + + def fields_are_different(col1: Collection, col2: Collection, field_name: str): + recursive_fields = field_name.split(".") + + f1 = col1 + f2 = col2 + for f in recursive_fields: + f1 = getattr(f1, f) + f2 = getattr(f2, f) + + if f1 != f2: + logger.info(f"{field_name} is different: '{f1}' != '{f2}'") + return True + return False + + fields = [ + "extent.spatial.bboxes", + "extent.temporal.intervals", + "description", + "id", + "keywords", + "license", + "strategy", + "providers", + "title", + ] + return any(fields_are_different(col1, col2, field) for field in fields) + + +def generate_items_diff( + col1: Collection, col2: Collection +) -> Tuple[List[Item], List[Item]]: + """Compute the diff between 2 STAC collections. + + Returns: + - list of items only in collection 1 + - list of items only in collection 2 + """ + + def item_get_unique(i: Item) -> str: + return i.id + UNIQUE_SEP + str(i.datetime.isoformat() if i.datetime else "") + + col1_ids = [item_get_unique(i) for i in col1.get_items()] + col2_ids = [item_get_unique(i) for i in col2.get_items()] + + only_in_1 = set(col1_ids) - set(col2_ids) + only_in_2 = set(col2_ids) - set(col1_ids) + + def unique_retrieve_info(unique: str, col: Collection) -> Item: + id = unique.split(UNIQUE_SEP)[0] + item = col.get_item(id) + if not item: + raise Exception(f"Item {id} not found") + return item + + list_only_in_1 = [unique_retrieve_info(unique, col1) for unique in only_in_1] + list_only_in_2 = [unique_retrieve_info(unique, col2) for unique in only_in_2] + + return list_only_in_1, list_only_in_2 + + +def compare_local_and_upstream( + handler: stac.StacTransactionsHandler, + local_col_path: str, + remote_col_id: str | None = None, +): + """Compare a local and a remote collection. + + Args: + handler (stac.StacTransactionHandler): object to handle the connection + local_col_path (str): path to local collection path + remote_col_id (str | None, optional): Remote collection identifier. + If unset, will take the same id as the local collection + """ + col_local = cast(Collection, stac.load_stac_obj(obj_pth=local_col_path)) + if not remote_col_id: + remote_col_id = col_local.id + col_remote = handler.get_remote_col(remote_col_id) + + only_local, only_remote = generate_items_diff(col_local, col_remote) + + collections_defs_are_different(col_local, col_remote) + + print(f"Only local ({len(only_local)}):") + print(only_local[:20]) + + print(f"Only remote ({len(only_remote)}):") + print(only_remote[:20]) diff --git a/theia_dumper/stac.py b/theia_dumper/stac.py index 1a9026849cf4ab2350b36485a84f68e22b63b9ab..6ea96ee05e08c198c3cecdbebb048c0781dede24 100644 --- a/theia_dumper/stac.py +++ b/theia_dumper/stac.py @@ -1,9 +1,11 @@ """STAC stuff.""" import os +import re from dataclasses import dataclass -from typing import List +from typing import List, cast from urllib.parse import urljoin +import operator import dinamis_sdk import pystac @@ -14,6 +16,9 @@ from requests.adapters import HTTPAdapter, Retry from .logger import logger +DEFAULT_STAC_EP = "https://stacapi-cdos.apps.okd.crocc.meso.umontpellier.fr" +DEFAULT_S3_EP = "https://s3-data.meso.umontpellier.fr" + class STACObjectUnresolved(Exception): """Unresolved STAC object exception.""" @@ -23,6 +28,14 @@ class UnconsistentCollectionIDs(Exception): """Inconsistent STAC collection exception.""" +def _check_naming_is_compliant(s: str, allow_dot=False): + _s = re.sub(r"[-|_]", r"", s) + if allow_dot: + _s = re.sub(r"\.", r"", _s) + if not _s.isalnum(): + raise Exception(f"{_s} does not only contain alphanumeric or - or _ chars") + + def create_session(): """Create a requests session.""" sess = requests.Session() @@ -54,6 +67,16 @@ def create_session(): return sess +def asset_exists(url: str) -> bool: + """Check that the item provided in parameter exists and is accessible.""" + sess = create_session() + res = sess.get(dinamis_sdk.sign(url), stream=True) + if res.status_code == 200: + logger.info("Asset %s already exists. Skipping.", url) + return True + return False + + def post_or_put(url: str, data: dict): """Post or put data to url.""" headers = dinamis_sdk.get_headers() @@ -78,7 +101,7 @@ def post_or_put(url: str, data: dict): raise e -def load(obj_pth): +def load_stac_obj(obj_pth: str) -> Collection | ItemCollection | Item: """Load a STAC object serialized on disk.""" for obj_name, cls in { "collection": Collection, @@ -108,7 +131,7 @@ def get_assets_root_dir(items: List[Item]) -> str: def check_items_collection_id(items: List[Item]): - """Check that items collection_id is unique.""" + """Check that items have the same collection_id.""" if len(set(item.collection_id for item in items)) > 1: raise UnconsistentCollectionIDs("Collection ID must be the same for all items!") @@ -125,37 +148,92 @@ def get_col_items(col: Collection) -> List[Item]: """Retrieve collection items.""" col_href = get_col_href(col=col) return [ - load( - os.path.join(os.path.dirname(col_href), link.href[2:]) - if link.href.startswith("./") - else link.href + cast( + Item, + load_stac_obj( + os.path.join(os.path.dirname(col_href), link.href[2:]) + if link.href.startswith("./") + else link.href + ), ) for link in col.links if link.rel == "item" ] -def delete_stac_obj(stac_endpoint: str, col_id: str, item_id: str | None = None): - """Delete an item or a collection.""" - logger.info("Deleting %s%s", col_id, f"/{item_id}" if item_id else "") - if item_id: - url = f"{stac_endpoint}/collections/{col_id}/items/{item_id}" - else: - url = f"{stac_endpoint}/collections/{col_id}" - resp = requests.delete( - url, - headers=dinamis_sdk.get_headers(), - timeout=5, - ) - if resp.status_code != 200: - logger.warning("Deletion failed (%s)", resp.text) +@dataclass +class StacTransactionsHandler: + """Handle STAC and storage transactions.""" + + stac_endpoint: str + + def delete_item_or_col(self, col_id: str, item_id: str | None = None): + """Delete an item or a collection.""" + logger.info("Deleting %s%s", col_id, f"/{item_id}" if item_id else "") + if item_id: + url = f"{self.stac_endpoint}/collections/{col_id}/items/{item_id}" + else: + url = f"{self.stac_endpoint}/collections/{col_id}" + resp = requests.delete( + url, + headers=dinamis_sdk.get_headers(), + timeout=5, + ) + if resp.status_code != 200: + logger.warning("Deletion failed (%s)", resp.text) + + def list_collections(self): + """List collections.""" + logger.info("Listing collections") + url = f"{self.stac_endpoint}/collections" + resp = requests.get( + url, + timeout=5, + ) + if resp.status_code != 200: + logger.warning("Get failed (%s)", resp.text) + cols = resp.json()["collections"] + cols.sort(key=operator.itemgetter("id")) + return cols + + def list_collections_display(self): + """Display in terminal a list of available collections.""" + cols = self.list_collections() + print(f"{len(cols)} collections available") + for col in cols: + print("\t" + col["id"]) + + def list_col_items(self, col_id: str, max_items=10): + """List items in a collection.""" + logger.info("Listing %s items", col_id) + api = pystac_client.Client.open( + self.stac_endpoint, + modifier=dinamis_sdk.sign_inplace, + ) + res = api.search(collections=[col_id], max_items=max_items) + items = list(res.items()) + return items + + def list_col_items_display(self, col_id: str, max_items=10): + """Display in terminal items in a collection.""" + items = self.list_col_items(col_id, max_items=max_items) + print(f"{len(items)} items found:") + for item in items: + print("\t" + item.id) + + def get_remote_col(self, col_id) -> Collection: + """Retrieve a remote collection.""" + api = pystac_client.Client.open( + self.stac_endpoint, + modifier=dinamis_sdk.sign_inplace, + ) + return api.get_collection(col_id) @dataclass -class TransactionsHandler: +class StacUploadTransactionsHandler(StacTransactionsHandler): """Handle STAC and storage transactions.""" - stac_endpoint: str storage_endpoint: str storage_bucket: str assets_overwrite: bool @@ -165,23 +243,27 @@ class TransactionsHandler: col_id = item.collection_id target_root_dir = urljoin(self.storage_endpoint, self.storage_bucket) + _check_naming_is_compliant(self.storage_bucket) + _check_naming_is_compliant(item.id) + # Upload assets files for _, asset in item.assets.items(): local_filename = asset.href logger.debug("Local file: %s", local_filename) target_url = local_filename.replace(assets_root_dir, target_root_dir) + + _check_naming_is_compliant( + target_url.replace(target_root_dir + "/", ""), allow_dot=True + ) logger.debug("Target file: %s", target_url) # Skip when target file exists and overwrite is not enabled if not self.assets_overwrite: - sess = create_session() - res = sess.get(dinamis_sdk.sign(target_url), stream=True) - if res.status_code == 200: - logger.info("Asset %s already exists. Skipping.", target_url) + if asset_exists(target_url): continue # Upload file - logger.info("Uploading %s ...", local_filename) + logger.info("Uploading %s to %s...", local_filename, target_url) try: dinamis_sdk.push(local_filename=local_filename, target_url=target_url) except Exception as e: @@ -229,6 +311,7 @@ class TransactionsHandler: def publish_collection(self, collection: Collection): """Publish an empty collection.""" + _check_naming_is_compliant(collection.id) post_or_put( url=urljoin(self.stac_endpoint, "/collections"), data=collection.to_dict() ) @@ -246,7 +329,7 @@ class TransactionsHandler: def load_and_publish(self, obj_pth: str): """Load and publish the serialized STAC object.""" - obj = load(obj_pth=obj_pth) + obj = load_stac_obj(obj_pth=obj_pth) if isinstance(obj, Collection): self.publish_collection_with_items(collection=obj) elif isinstance(obj, ItemCollection): @@ -255,9 +338,3 @@ class TransactionsHandler: raise TypeError( f"Invalid type, must be ItemCollection or Collection (got {type(obj)})" ) - - def delete(self, col_id: str, item_id: str | None = None): - """Delete an item or a collection.""" - delete_stac_obj( - stac_endpoint=self.stac_endpoint, col_id=col_id, item_id=item_id - )