From 65d5eac338fb1e3ce9d5b4338d154020dedb1f12 Mon Sep 17 00:00:00 2001 From: Benjamin Goldenberg Date: Wed, 9 Apr 2025 15:03:14 -0700 Subject: [PATCH 1/6] Support bulk subs creation in client and add --bulk to CLI to invoke it --- planet/cli/subscriptions.py | 16 +++++++++++++--- planet/clients/subscriptions.py | 30 +++++++++++++++++++++++++++++- planet/sync/subscriptions.py | 18 +++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 2b1602982..1f2f67770 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -151,6 +151,11 @@ async def list_subscriptions_cmd(ctx, @subscriptions.command(name="create") # type: ignore @click.argument("request", type=types.JSON()) +@click.option( + "--bulk", + is_flag=True, + help="Bulk create many subscriptions using a feature collection reference.", +) @click.option( "--hosting", type=click.Choice([ @@ -198,9 +203,14 @@ async def create_subscription_cmd(ctx, request, pretty, **kwargs): hosting_info = sentinel_hub(collection_id, create_configuration) request["hosting"] = hosting_info - async with subscriptions_client(ctx) as client: - sub = await client.create_subscription(request) - echo_json(sub, pretty) + if kwargs.get("bulk"): + async with subscriptions_client(ctx) as client: + _ = await client.bulk_create_subscriptions([request]) + # Bulk create returns no response, so we don't echo anything + else: + async with subscriptions_client(ctx) as client: + sub = await client.create_subscription(request) + echo_json(sub, pretty) @subscriptions.command(name='cancel') # type: ignore diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py index df1638cf0..8c01f6605 100644 --- a/planet/clients/subscriptions.py +++ b/planet/clients/subscriptions.py @@ -1,7 +1,7 @@ """Planet Subscriptions API Python client.""" import logging -from typing import Any, AsyncIterator, Awaitable, Dict, Optional, Sequence, TypeVar +from typing import Any, AsyncIterator, Awaitable, Dict, Optional, Sequence, TypeVar, List from typing_extensions import Literal @@ -203,6 +203,34 @@ async def create_subscription(self, request: dict) -> dict: sub = resp.json() return sub + async def bulk_create_subscriptions(self, requests: List[dict]) -> None: + """ + Create multiple subscriptions in bulk. Currently, the list of requests can only contain one item. + + Args: + requests (List[dict]): A list of dictionaries where each dictionary + represents a subscription to be created. + + Raises: + APIError: If the API returns an error response. + ClientError: If there is an issue with the client request. + + Returns: + None: The bulk create endpoint returns an empty body, so no value + is returned. + """ + try: + url = f'{self._base_url}/bulk' + _ = await self._session.request(method='POST', + url=url, + json={'subscriptions': requests}) + # Forward APIError. We don't strictly need this clause, but it + # makes our intent clear. + except APIError: + raise + except ClientError: # pragma: no cover + raise + async def cancel_subscription(self, subscription_id: str) -> None: """Cancel a Subscription. diff --git a/planet/sync/subscriptions.py b/planet/sync/subscriptions.py index 72e4615a8..134a4b7da 100644 --- a/planet/sync/subscriptions.py +++ b/planet/sync/subscriptions.py @@ -1,6 +1,6 @@ """Planet Subscriptions API Python client.""" -from typing import Any, Dict, Iterator, Optional, Sequence, Union +from typing import Any, Dict, Iterator, Optional, Sequence, Union, List from typing_extensions import Literal @@ -136,6 +136,22 @@ def create_subscription(self, request: Dict) -> Dict: return self._client._call_sync( self._client.create_subscription(request)) + def bulk_create_subscriptions(self, requests: List[Dict]) -> None: + """Bulk create subscriptions. + + Args: + request (List[dict]): list of descriptions of a bulk creation. + + Returns: + None + + Raises: + APIError: on an API server error. + ClientError: on a client error. + """ + return self._client._call_sync( + self._client.bulk_create_subscriptions(requests)) + def cancel_subscription(self, subscription_id: str) -> None: """Cancel a Subscription. From 607e62ecbe7e9580cce95f45e707996637730b59 Mon Sep 17 00:00:00 2001 From: Benjamin Goldenberg Date: Mon, 14 Apr 2025 14:42:35 -0700 Subject: [PATCH 2/6] Update to parse referenced list of subs, and add an integration test --- planet/cli/subscriptions.py | 5 +++-- planet/clients/subscriptions.py | 12 +++++------ planet/sync/subscriptions.py | 4 ++-- tests/integration/test_subscriptions_api.py | 23 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 1f2f67770..6db99b579 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -205,8 +205,9 @@ async def create_subscription_cmd(ctx, request, pretty, **kwargs): if kwargs.get("bulk"): async with subscriptions_client(ctx) as client: - _ = await client.bulk_create_subscriptions([request]) - # Bulk create returns no response, so we don't echo anything + links = await client.bulk_create_subscriptions([request]) + # Bulk create returns just a link to an endpoint to list created subscriptions. + echo_json(links, pretty) else: async with subscriptions_client(ctx) as client: sub = await client.create_subscription(request) diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py index 8c01f6605..0638ec102 100644 --- a/planet/clients/subscriptions.py +++ b/planet/clients/subscriptions.py @@ -203,7 +203,7 @@ async def create_subscription(self, request: dict) -> dict: sub = resp.json() return sub - async def bulk_create_subscriptions(self, requests: List[dict]) -> None: + async def bulk_create_subscriptions(self, requests: List[dict]) -> Dict: """ Create multiple subscriptions in bulk. Currently, the list of requests can only contain one item. @@ -216,20 +216,20 @@ async def bulk_create_subscriptions(self, requests: List[dict]) -> None: ClientError: If there is an issue with the client request. Returns: - None: The bulk create endpoint returns an empty body, so no value - is returned. + The response including a _links key to the list endpoint for use finding the created subscriptions. """ try: url = f'{self._base_url}/bulk' - _ = await self._session.request(method='POST', - url=url, - json={'subscriptions': requests}) + resp = await self._session.request( + method='POST', url=url, json={'subscriptions': requests}) # Forward APIError. We don't strictly need this clause, but it # makes our intent clear. except APIError: raise except ClientError: # pragma: no cover raise + else: + return resp.json() async def cancel_subscription(self, subscription_id: str) -> None: """Cancel a Subscription. diff --git a/planet/sync/subscriptions.py b/planet/sync/subscriptions.py index 134a4b7da..f22f899a2 100644 --- a/planet/sync/subscriptions.py +++ b/planet/sync/subscriptions.py @@ -136,14 +136,14 @@ def create_subscription(self, request: Dict) -> Dict: return self._client._call_sync( self._client.create_subscription(request)) - def bulk_create_subscriptions(self, requests: List[Dict]) -> None: + def bulk_create_subscriptions(self, requests: List[Dict]) -> Dict: """Bulk create subscriptions. Args: request (List[dict]): list of descriptions of a bulk creation. Returns: - None + response including link to list of created subscriptions Raises: APIError: on an API server error. diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py index 23d99d32d..6bd914e80 100644 --- a/tests/integration/test_subscriptions_api.py +++ b/tests/integration/test_subscriptions_api.py @@ -122,6 +122,17 @@ def modify_response(request): create_mock.route(M(url=TEST_URL), method='POST').mock(side_effect=modify_response) +bulk_create_mock = respx.mock() +bulk_create_mock.route( + M(url=f'{TEST_URL}/bulk'), method='POST' +).mock(return_value=Response( + 200, + json={ + '_links': { + 'list': f'{TEST_URL}/subscriptions/v1?created={datetime.now().isoformat()}/&geom_ref=pl:features:test_features&name=test-sub' + } + })) + update_mock = respx.mock() update_mock.route(M(url=f'{TEST_URL}/test'), method='PUT').mock(side_effect=modify_response) @@ -334,6 +345,18 @@ async def test_create_subscription_success(): assert sub['name'] == 'test' +@pytest.mark.anyio +@bulk_create_mock +async def test_bulk_create_subscription_success(): + """Bulk subscription is created, description has the expected items.""" + async with Session() as session: + client = SubscriptionsClient(session, base_url=TEST_URL) + resp = await client.bulk_create_subscriptions([{ + 'name': 'test', 'delivery': 'yes, please', 'source': 'test' + }]) + assert '/subscriptions/v1?' in resp['_links']['list'] + + @create_mock def test_create_subscription_success_sync(): """Subscription is created, description has the expected items.""" From 79e209dd7448c169c5f86e9bd9c294cd9b1b7955 Mon Sep 17 00:00:00 2001 From: Benjamin Goldenberg Date: Tue, 15 Apr 2025 15:29:33 -0700 Subject: [PATCH 3/6] Add test_bulk_create_subscription_success_sync --- tests/integration/test_subscriptions_api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py index 6bd914e80..790736697 100644 --- a/tests/integration/test_subscriptions_api.py +++ b/tests/integration/test_subscriptions_api.py @@ -369,6 +369,18 @@ def test_create_subscription_success_sync(): assert sub['name'] == 'test' +@bulk_create_mock +def test_bulk_create_subscription_success_sync(): + """Subscription is created, description has the expected items.""" + + pl = Planet() + pl.subscriptions._client._base_url = TEST_URL + resp = pl.subscriptions.bulk_create_subscriptions({ + 'name': 'test', 'delivery': 'yes, please', 'source': 'test' + }) + assert '/subscriptions/v1?' in resp['_links']['list'] + + @pytest.mark.anyio @create_mock async def test_create_subscription_with_hosting_success(): From 7678ace59b5a14ac8a1cdc2e6190cc2a8f8c4cba Mon Sep 17 00:00:00 2001 From: Benjamin Goldenberg Date: Tue, 15 Apr 2025 15:44:54 -0700 Subject: [PATCH 4/6] Define dedicated `subscriptions bulk-create` command instead of --bulk option on create --- planet/cli/subscriptions.py | 71 +++++++++++++++++---- tests/integration/test_subscriptions_cli.py | 23 +++++++ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 6db99b579..8b91f0f24 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -151,11 +151,6 @@ async def list_subscriptions_cmd(ctx, @subscriptions.command(name="create") # type: ignore @click.argument("request", type=types.JSON()) -@click.option( - "--bulk", - is_flag=True, - help="Bulk create many subscriptions using a feature collection reference.", -) @click.option( "--hosting", type=click.Choice([ @@ -203,15 +198,63 @@ async def create_subscription_cmd(ctx, request, pretty, **kwargs): hosting_info = sentinel_hub(collection_id, create_configuration) request["hosting"] = hosting_info - if kwargs.get("bulk"): - async with subscriptions_client(ctx) as client: - links = await client.bulk_create_subscriptions([request]) - # Bulk create returns just a link to an endpoint to list created subscriptions. - echo_json(links, pretty) - else: - async with subscriptions_client(ctx) as client: - sub = await client.create_subscription(request) - echo_json(sub, pretty) + async with subscriptions_client(ctx) as client: + sub = await client.create_subscription(request) + echo_json(sub, pretty) + + +@subscriptions.command(name="bulk-create") # type: ignore +@click.argument("request", type=types.JSON()) +@click.option( + "--hosting", + type=click.Choice([ + "sentinel_hub", + ]), + default=None, + help='Hosting type. Currently, only "sentinel_hub" is supported.', +) +@click.option("--collection-id", + default=None, + help='Collection ID for Sentinel Hub hosting. ' + 'If omitted, a new collection will be created.') +@click.option( + '--create-configuration', + is_flag=True, + help='Automatically create a layer configuration for your collection. ' + 'If omitted, no configuration will be created.') +@pretty +@click.pass_context +@translate_exceptions +@coro +async def bulk_create_subscription_cmd(ctx, request, pretty, **kwargs): + """Bulk create subscriptions. + + Submits a bulk subscription request for creation and prints a link to list + the resulting subscriptions. + + REQUEST is the full description of the subscription to be created. It must + be JSON and can be specified a json string, filename, or '-' for stdin. + + Other flag options are hosting, collection_id, and create_configuration. + The hosting flag specifies the hosting type, the collection_id flag specifies the + collection ID for Sentinel Hub, and the create_configuration flag specifies + whether or not to create a layer configuration for your collection. If the + collection_id is omitted, a new collection will be created. If the + create_configuration flag is omitted, no configuration will be created. The + collection_id flag and create_configuration flag cannot be used together. + """ + hosting = kwargs.get("hosting", None) + collection_id = kwargs.get("collection_id", None) + create_configuration = kwargs.get('create_configuration', False) + + if hosting == "sentinel_hub": + hosting_info = sentinel_hub(collection_id, create_configuration) + request["hosting"] = hosting_info + + async with subscriptions_client(ctx) as client: + links = await client.bulk_create_subscriptions([request]) + # Bulk create returns just a link to an endpoint to list created subscriptions. + echo_json(links, pretty) @subscriptions.command(name='cancel') # type: ignore diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py index dcdaf293d..b911f7daa 100644 --- a/tests/integration/test_subscriptions_cli.py +++ b/tests/integration/test_subscriptions_cli.py @@ -25,6 +25,7 @@ from test_subscriptions_api import (api_mock, cancel_mock, create_mock, + bulk_create_mock, failing_api_mock, get_mock, patch_mock, @@ -138,6 +139,28 @@ def test_subscriptions_create_success(invoke, cmd_arg, runner_input): assert result.exit_code == 0 # success. +@pytest.mark.parametrize('cmd_arg, runner_input', + [('-', json.dumps(GOOD_SUB_REQUEST)), + (json.dumps(GOOD_SUB_REQUEST), None), + ('-', json.dumps(GOOD_SUB_REQUEST_WITH_HOSTING)), + (json.dumps(GOOD_SUB_REQUEST_WITH_HOSTING), None)]) +@bulk_create_mock +def test_subscriptions_bulk_create_success(invoke, cmd_arg, runner_input): + """Subscriptions creation succeeds with a valid subscription request.""" + + # The "-" argument says "read from stdin" and the input keyword + # argument specifies what bytes go to the runner's stdin. + result = invoke( + ['bulk-create', cmd_arg], + input=runner_input, + # Note: catch_exceptions=True (the default) is required if we want + # to exercise the "translate_exceptions" decorator and test for + # failure. + catch_exceptions=True) + + assert result.exit_code == 0 # success. + + # Invalid JSON. BAD_SUB_REQUEST = '{0: "lolwut"}' From 0ed8e82c18d72ed15784137ab1680a9b0a1358a8 Mon Sep 17 00:00:00 2001 From: Benjamin Goldenberg Date: Wed, 16 Apr 2025 16:49:46 -0700 Subject: [PATCH 5/6] Add documentation for new bulk cli command --- docs/cli/cli-subscriptions.md | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/cli/cli-subscriptions.md b/docs/cli/cli-subscriptions.md index c84e6a9fc..ea0c683e3 100644 --- a/docs/cli/cli-subscriptions.md +++ b/docs/cli/cli-subscriptions.md @@ -247,6 +247,49 @@ planet subscriptions patch cb817760-1f07-4ee7-bba6-bcac5346343f \ patched-attributes.json ``` +### Bulk Create Subscriptions + +To create many subscriptions for different geometries at once, use the `bulk-create` subcommand. + +This command allows submitting a bulk create request that references a feature collection defined in +the Features API, which will create a subscription for every feature in the collection. + +Define a subscription that references a feature collection: + +```json +{ + "name": "new guinea psscene bulk subscription", + "source": { + "parameters": { + "item_types": [ + "PSScene" + ], + "asset_types": [ + "ortho_visual" + ], + "geometry": { + "type": "ref", + "content": "pl:features/my/test-new-guinea-10geojson-xqRXaaZ" + }, + "start_time": "2021-01-01T00:00:00Z", + "end_time": "2021-01-05T00:00:00Z" + } + } +} +``` + +And issue the `bulk-create` command with the appropriate hosting or delivery options. A link to list +the resulting subscriptions will be returned: + +```sh +planet subscriptions bulk-create --hosting sentinel_hub catalog_fc_sub.json +{ + "_links": { + "list": "https://api.planet.com/subscriptions/v1?created=2025-04-16T23%3A44%3A35Z%2F..&geom_ref=pl%3Afeatures%2Fmy%2Ftest-new-guinea-10geojson-xqRXaaZ&name=new+guinea+psscene+bulk subscription" + } +} +``` + ### Cancel Subscription Cancelling a subscription is simple with the CLI: From 8c36027d9c633eb526205620fbb1d1e23384b1b2 Mon Sep 17 00:00:00 2001 From: Benjamin Goldenberg Date: Wed, 16 Apr 2025 16:53:02 -0700 Subject: [PATCH 6/6] fixup! Add test_bulk_create_subscription_success_sync --- tests/integration/test_subscriptions_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py index 790736697..25b299de9 100644 --- a/tests/integration/test_subscriptions_api.py +++ b/tests/integration/test_subscriptions_api.py @@ -375,9 +375,9 @@ def test_bulk_create_subscription_success_sync(): pl = Planet() pl.subscriptions._client._base_url = TEST_URL - resp = pl.subscriptions.bulk_create_subscriptions({ + resp = pl.subscriptions.bulk_create_subscriptions([{ 'name': 'test', 'delivery': 'yes, please', 'source': 'test' - }) + }]) assert '/subscriptions/v1?' in resp['_links']['list']