diff --git a/planet/cli/features.py b/planet/cli/features.py index 91ebb7e1..0c97c231 100644 --- a/planet/cli/features.py +++ b/planet/cli/features.py @@ -98,6 +98,19 @@ async def collection_get(ctx, collection_id, pretty): echo_json(result, pretty) +@command(collections, name="delete") +@click.argument("collection_id", required=True) +async def collection_delete(ctx, collection_id, *args, **kwargs): + """Delete a collection by ID + + Example: + + planet features collections delete my-collection-123 + """ + async with features_client(ctx) as cl: + await cl.delete_collection(collection_id) + + @features.group() def items(): """commands for interacting with Features API items (features @@ -150,6 +163,36 @@ async def item_get(ctx, collection_id, feature_id, pretty): echo_json(feature, pretty) +@command(items, name="delete") +@click.argument("collection_id") +@click.argument("feature_id", required=False) +async def item_delete(ctx, collection_id, feature_id, *args, **kwargs): + """Delete a feature in a collection. + + You may supply either a collection ID and a feature ID, or + a feature reference. + + Example: + + planet features items delete my-collection-123 item123 + planet features items delete "pl:features/my/my-collection-123/item123" + """ + + # ensure that either collection_id and feature_id were supplied, or that + # a feature ref was supplied as a single value. + if not ((collection_id and feature_id) or + ("pl:features" in collection_id)): + raise ClickException( + "Must supply either collection_id and feature_id, or a valid feature reference." + ) + + if collection_id.startswith("pl:features"): + collection_id, feature_id = split_ref(collection_id) + + async with features_client(ctx) as cl: + await cl.delete_item(collection_id, feature_id) + + @command(items, name="add") @click.argument("collection_id", required=True) @click.argument("filename", required=True) diff --git a/planet/clients/features.py b/planet/clients/features.py index c0a353d2..4aa3483b 100644 --- a/planet/clients/features.py +++ b/planet/clients/features.py @@ -16,6 +16,7 @@ from typing import Any, AsyncIterator, Optional, Union, TypeVar from planet.clients.base import _BaseClient +from planet.exceptions import ClientError from planet.http import Session from planet.models import Feature, GeoInterface, Paged from planet.constants import PLANET_BASE_URL @@ -167,6 +168,31 @@ async def get_item(self, collection_id: str, feature_id: str) -> Feature: response = await self._session.request(method='GET', url=url) return Feature(**response.json()) + async def delete_item(self, collection_id: str, feature_id: str) -> None: + """ + Delete a feature from a collection. + + Parameters: + collection_id: The ID of the collection containing the feature + feature_id: The ID of the feature to delete + + Example: + + ``` + await features_client.delete_item( + collection_id="my-collection", + feature_id="feature-123" + ) + ``` + """ + + # fail early instead of sending a delete request without a feature id. + if len(feature_id) < 1: + raise ClientError("Must provide a feature id") + + url = f'{self._base_url}/collections/{collection_id}/items/{feature_id}' + await self._session.request(method='DELETE', url=url) + async def create_collection(self, title: str, description: Optional[str] = None) -> str: @@ -192,6 +218,29 @@ async def create_collection(self, return resp.json()["id"] + async def delete_collection(self, collection_id: str) -> None: + """ + Delete a collection. + + Parameters: + collection_id: The ID of the collection to delete + + Example: + + ``` + await features_client.delete_collection( + collection_id="my-collection" + ) + ``` + """ + + # fail early instead of sending a delete request without a collection id. + if len(collection_id) < 1: + raise ClientError("Must provide a collection id") + + url = f'{self._base_url}/collections/{collection_id}' + await self._session.request(method='DELETE', url=url) + async def add_items(self, collection_id: str, feature: Union[dict, GeoInterface], diff --git a/planet/sync/features.py b/planet/sync/features.py index fec47519..c4cbee69 100644 --- a/planet/sync/features.py +++ b/planet/sync/features.py @@ -78,6 +78,23 @@ def create_collection(self, collection = self._client.create_collection(title, description) return self._client._call_sync(collection) + def delete_collection(self, collection_id: str) -> None: + """ + Delete a collection. + + Parameters: + collection_id: The ID of the collection to delete + + Example: + + ``` + pl = Planet() + pl.features.delete_collection(collection_id="my-collection") + ``` + """ + return self._client._call_sync( + self._client.delete_collection(collection_id)) + def list_items(self, collection_id: str, limit: int = 0) -> Iterator[Feature]: @@ -114,6 +131,24 @@ def get_item(self, collection_id: str, feature_id: str) -> Feature: return self._client._call_sync( self._client.get_item(collection_id, feature_id)) + def delete_item(self, collection_id: str, feature_id: str) -> None: + """ + Delete a feature from a collection. + + Parameters: + collection_id: The ID of the collection containing the feature + feature_id: The ID of the feature to delete + + Example: + + ``` + pl = Planet() + pl.features.delete_item(collection_id="my-collection", feature_id="feature-123") + ``` + """ + return self._client._call_sync( + self._client.delete_item(collection_id, feature_id)) + def add_items(self, collection_id: str, feature: Union[dict, GeoInterface], diff --git a/tests/integration/test_features_api.py b/tests/integration/test_features_api.py index 4ae55483..1192244a 100644 --- a/tests/integration/test_features_api.py +++ b/tests/integration/test_features_api.py @@ -59,8 +59,11 @@ def __geo_interface__(self) -> dict: return TEST_GEOM -def mock_response(url: str, json: Any, method: str = "get"): - mock_resp = httpx.Response(HTTPStatus.OK, json=json) +def mock_response(url: str, + json: Any, + method: str = "get", + status_code: int = HTTPStatus.OK): + mock_resp = httpx.Response(status_code, json=json) respx.request(method, url).return_value = mock_resp @@ -260,3 +263,53 @@ def assertf(resp): req_body = json.loads(respx.calls[0].request.content) assert req_body["type"] == "Feature" assert req_body["geometry"] == expected_body + + +@respx.mock +async def test_get_item(): + collection_id = "test" + item_id = "test123" + items_url = f"{TEST_URL}/collections/{collection_id}/items/{item_id}" + + mock_response(items_url, to_feature_model(item_id)) + + def assertf(resp): + assert resp["id"] == item_id + + assertf(await cl_async.get_item(collection_id, item_id)) + assertf(cl_sync.get_item(collection_id, item_id)) + + +@respx.mock +async def test_delete_item(): + collection_id = "test" + item_id = "test123" + items_url = f"{TEST_URL}/collections/{collection_id}/items/{item_id}" + + mock_response(items_url, + json=None, + method="delete", + status_code=HTTPStatus.NO_CONTENT) + + def assertf(resp): + assert resp is None + + assertf(await cl_async.delete_item(collection_id, item_id)) + assertf(cl_sync.delete_item(collection_id, item_id)) + + +@respx.mock +async def test_delete_collection(): + collection_id = "test" + collections_url = f"{TEST_URL}/collections/{collection_id}" + + mock_response(collections_url, + json=None, + method="delete", + status_code=HTTPStatus.NO_CONTENT) + + def assertf(resp): + assert resp is None + + assertf(await cl_async.delete_collection(collection_id)) + assertf(cl_sync.delete_collection(collection_id)) diff --git a/tests/integration/test_features_cli.py b/tests/integration/test_features_cli.py index efc772ad..c024fc61 100644 --- a/tests/integration/test_features_cli.py +++ b/tests/integration/test_features_cli.py @@ -1,3 +1,4 @@ +from http import HTTPStatus import tempfile import json import pytest @@ -6,7 +7,7 @@ from click.testing import CliRunner from planet.cli import cli -from tests.integration.test_features_api import TEST_COLLECTION_1, TEST_COLLECTION_LIST, TEST_FEAT, TEST_GEOM, TEST_URL, list_collections_response, list_features_response, mock_response, to_collection_model +from tests.integration.test_features_api import TEST_COLLECTION_1, TEST_COLLECTION_LIST, TEST_FEAT, TEST_GEOM, TEST_URL, list_collections_response, list_features_response, mock_response, to_collection_model, to_feature_model def invoke(*args): @@ -17,7 +18,11 @@ def invoke(*args): result = runner.invoke(cli.main, args=args) assert result.exit_code == 0, result.output - return json.loads(result.output) + if len(result.output) > 0: + return json.loads(result.output) + + # some commands (delete) return no value. + return None @respx.mock @@ -122,3 +127,57 @@ def assertf(resp): req_body = json.loads(respx.calls[0].request.content) assert req_body["type"] == "Feature" assert req_body["geometry"] == expected_body + + +@respx.mock +def test_get_item(): + collection_id = "test" + item_id = "test123" + get_item_url = f'{TEST_URL}/collections/{collection_id}/items/{item_id}' + + mock_response(get_item_url, + json=to_feature_model("test123"), + method="get", + status_code=HTTPStatus.OK) + + def assertf(resp): + assert resp["id"] == "test123" + + assertf(invoke("items", "get", collection_id, item_id)) + assertf(invoke("items", "get", + f"pl:features/my/{collection_id}/{item_id}")) + + +@respx.mock +def test_delete_item(): + collection_id = "test" + item_id = "test123" + delete_item_url = f'{TEST_URL}/collections/{collection_id}/items/{item_id}' + + mock_response(delete_item_url, + json=None, + method="delete", + status_code=HTTPStatus.NO_CONTENT) + + def assertf(resp): + assert resp is None + + assertf(invoke("items", "delete", collection_id, item_id)) + assertf( + invoke("items", "delete", f"pl:features/my/{collection_id}/{item_id}")) + + +@respx.mock +def test_delete_collection(): + collection_id = "test" + collection_url = f'{TEST_URL}/collections/{collection_id}' + + mock_response(collection_url, + json=None, + method="delete", + status_code=HTTPStatus.NO_CONTENT) + + def assertf(resp): + assert resp is None + + assertf(invoke("collections", "delete", collection_id))