Repository: planetlabs/planet-client-python
Pull request #1021 (2024-02-07 16:08:14)

Subscriptions API Hosting Support for Sentinel Hub
**Related Issue(s):**

Closes # Internal DND-879


**Proposed Changes:**

For inclusion in changelog (if applicable):

1.  Support Subscriptions API hosting block for Sentinel Hub.

Not intended for changelog:

1. 

**Diff of User Interface**

Old behavior:
- Delivery block is required and hosting block is not supported.

New behavior:
- CLI and Python SDK both support specifying a hosting block, and delivery block is no longer required.




**PR Checklist:**

- [x] This PR is as small and focused as possible
- [x] If this PR includes proposed changes for inclusion in the changelog, the title of this PR summarizes those changes and is ready for inclusion in the Changelog.
- [x] I have updated docstrings for function changes and docs in the 'docs' folder for user interface / behavior changes
- [x] This PR does not break any examples or I have updated them

**(Optional) @mentions for Notifications:**



Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 20ec8c9..f33079d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,7 +33,7 @@ git checkout -b new-branch-name
 
 #### Branch Naming
 
-Please use the following naming convention for development branches:
+Please use the following naming convention for development branchs:
 
 `{up to 3-word summary of topic, separated by a dash)-{ticket number}`
 
@@ -41,7 +41,7 @@ For example: `release-contributing-691` for [ticket 691](https://github.com/plan
 
 ### Pull Requests
 
-NOTE: Make sure to set the appropriate base branch for PRs. See Development Branch above for appropriate branch.
+NOTE: Make sure to set the appropriate base branch for PRs. See Development Branch above for appriopriate branch.
 
 The Pull Request requirements are included in the pull request template as a list of checkboxes.
 
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index b3bfe2b..6034d1f 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -127,7 +127,7 @@ async def cancel_subscription_cmd(ctx, subscription_id, pretty):
 @translate_exceptions
 @coro
 async def update_subscription_cmd(ctx, subscription_id, request, pretty):
-    """Update a subscription via PUT.
+    """Update a subscription.
 
     Updates a subscription and prints the updated subscription description,
     optionally pretty-printed.
@@ -140,27 +140,6 @@ async def update_subscription_cmd(ctx, subscription_id, request, pretty):
         echo_json(sub, pretty)
 
 
-@subscriptions.command(name='patch')  # type: ignore
-@click.argument('subscription_id')
-@click.argument('request', type=types.JSON())
-@pretty
-@click.pass_context
-@translate_exceptions
-@coro
-async def patch_subscription_cmd(ctx, subscription_id, request, pretty):
-    """Update a subscription via PATCH.
-
-    Updates a subscription and prints the updated subscription description,
-    optionally pretty-printed.
-
-    REQUEST only requires the attributes to be changed. It must be
-    JSON and can be specified a json string, filename, or '-' for stdin.
-    """
-    async with subscriptions_client(ctx) as client:
-        sub = await client.patch_subscription(subscription_id, request)
-        echo_json(sub, pretty)
-
-
 @subscriptions.command(name='get')  # type: ignore
 @click.argument('subscription_id')
 @pretty
@@ -249,7 +228,6 @@ async def list_subscription_results_cmd(ctx,
               help='Source JSON. Can be a string, filename, or - for stdin.')
 @click.option(
     '--delivery',
-    required=True,
     type=types.JSON(),
     help=("Delivery configuration, including credentials for a cloud "
           "storage provider, to enable cloud delivery of data. Can be a "
@@ -262,6 +240,10 @@ async def list_subscription_results_cmd(ctx,
     '--tools',
     type=types.JSON(),
     help='Toolchain JSON. Can be a string, filename, or - for stdin.')
+@click.option(
+    '--hosting',
+    type=types.JSON(),
+    help='Hosting JSON.  Can be a string, a filename, or - for stdin.')
 @click.option(
     '--clip-to-source',
     is_flag=True,
@@ -273,6 +255,7 @@ def request(name,
             delivery,
             notifications,
             tools,
+            hosting,
             clip_to_source,
             pretty):
     """Generate a subscriptions request.
@@ -287,6 +270,7 @@ def request(name,
                                              delivery,
                                              notifications=notifications,
                                              tools=tools,
+                                             hosting=hosting,
                                              clip_to_source=clip_to_source)
     echo_json(res, pretty)
 
diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py
index 5717f55..bf92193 100644
--- a/planet/clients/subscriptions.py
+++ b/planet/clients/subscriptions.py
@@ -160,12 +160,11 @@ class SubscriptionsClient:
 
     async def update_subscription(self, subscription_id: str,
                                   request: dict) -> dict:
-        """Update (edit) a Subscription via PUT.
+        """Update (edit) a Subscription.
 
         Args
             subscription_id (str): id of the subscription to update.
-            request (dict): subscription content for update, full
-                payload is required.
+            request (dict): subscription content for update.
 
         Returns:
             dict: description of the updated subscription.
@@ -190,38 +189,6 @@ class SubscriptionsClient:
             sub = resp.json()
             return sub
 
-    async def patch_subscription(self, subscription_id: str,
-                                 request: dict) -> dict:
-        """Update (edit) a Subscription via PATCH.
-
-        Args
-            subscription_id (str): id of the subscription to update.
-            request (dict): subscription content for update, only
-                attributes to update are required.
-
-        Returns:
-            dict: description of the updated subscription.
-
-        Raises:
-            APIError: on an API server error.
-            ClientError: on a client error.
-        """
-        url = f'{self._base_url}/{subscription_id}'
-
-        try:
-            resp = await self._session.request(method='PATCH',
-                                               url=url,
-                                               json=request)
-        # 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:
-            sub = resp.json()
-            return sub
-
     async def get_subscription(self, subscription_id: str) -> dict:
         """Get a description of a Subscription.
 
diff --git a/planet/subscription_request.py b/planet/subscription_request.py
index fd1273c..520e341 100644
--- a/planet/subscription_request.py
+++ b/planet/subscription_request.py
@@ -49,9 +49,10 @@ REPROJECT_KERNEL_DEFAULT = 'near'
 
 def build_request(name: str,
                   source: Mapping,
-                  delivery: Mapping,
+                  delivery: Optional[Mapping] = None,
                   notifications: Optional[Mapping] = None,
                   tools: Optional[List[Mapping]] = None,
+                  hosting: Optional[Mapping] = None,
                   clip_to_source: Optional[bool] = False) -> dict:
     """Construct a Subscriptions API request.
 
@@ -65,6 +66,7 @@ def build_request(name: str,
         notifications: Specify notifications via email/webhook.
         tools: Tools to apply to the products. The order of operation
             is determined by the service.
+        hosting: A hosting destination e.g. Sentinel Hub.
         clip_to_source: whether to clip to the source geometry or not
             (the default). If True a clip configuration will be added to
             the list of requested tools unless an existing clip tool
@@ -106,17 +108,20 @@ def build_request(name: str,
 
         delivery = amazon_s3(ACCESS_KEY_ID, SECRET_ACCESS_KEY, "test", "us-east-1")
 
+        hosting = sentinel_hub("2716077c-191e-4e47-9e3f-01c9c429f88d")
+
         subscription_request = build_request(
-            "test_subscription", source=source, delivery=delivery
+            "test_subscription", source=source, delivery=delivery, hosting=hosting
         )
         ```
     """
-    # Because source and delivery are Mappings we must make copies for
+    # Because source is a Mapping we must make copies for
     # the function's return value. dict() shallow copies a Mapping
     # and returns a new dict.
-    details = {
-        "name": name, "source": dict(source), "delivery": dict(delivery)
-    }
+    details = {"name": name, "source": dict(source)}
+
+    if delivery:
+        details['delivery'] = dict(delivery)
 
     if notifications:
         details['notifications'] = dict(notifications)
@@ -145,6 +150,9 @@ def build_request(name: str,
 
         details['tools'] = tool_list
 
+    if hosting:
+        details['hosting'] = dict(hosting)
+
     return details
 
 
@@ -735,3 +743,24 @@ def cloud_filter_tool(
             }
 
     return _tool("cloud_filter", result)
+
+
+def _hosting(type: str, parameters: dict) -> dict:
+    return {"type": type, "parameters": parameters}
+
+
+def sentinel_hub(collection_id: Optional[str]) -> dict:
+    """Specify a Sentinel Hub hosting destination.
+
+    Requires the user to have a Sentinel Hub account linked with their Planet
+    account.  Subscriptions API will create a new collection to deliver data to
+    if collection_id is omitted from the request.
+
+    Parameters:
+        collection_id: Sentinel Hub collection
+    """
+
+    parameters = {}
+    if collection_id:
+        parameters['collection_id'] = collection_id
+    return _hosting("sentinel_hub", parameters)


Test patch:
diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py
index aef5fa8..74ed82d 100644
--- a/tests/integration/test_subscriptions_api.py
+++ b/tests/integration/test_subscriptions_api.py
@@ -72,8 +72,8 @@ api_mock.route(M(url=TEST_URL)).mock(
 
 
 # The "creation", "update", and "cancel" mock APIs return submitted
-# data to the caller. They are used to test methods that rely on POST,
-# PATCH, or PUT.
+# data to the caller. They are used to test methods that rely on POST
+# or PUT.
 def modify_response(request):
     if request.content:
         return Response(200, json=json.loads(request.content))
@@ -89,10 +89,6 @@ update_mock = respx.mock()
 update_mock.route(M(url=f'{TEST_URL}/test'),
                   method='PUT').mock(side_effect=modify_response)
 
-patch_mock = respx.mock()
-patch_mock.route(M(url=f'{TEST_URL}/test'),
-                 method='PATCH').mock(side_effect=modify_response)
-
 cancel_mock = respx.mock()
 cancel_mock.route(M(url=f'{TEST_URL}/test/cancel'),
                   method='POST').mock(side_effect=modify_response)
@@ -204,6 +200,18 @@ async def test_create_subscription_success():
         assert sub['name'] == 'test'
 
 
+@pytest.mark.anyio
+@create_mock
+async def test_create_subscription_with_hosting_success():
+    """Subscription is created, description has the expected items."""
+    async with Session() as session:
+        client = SubscriptionsClient(session, base_url=TEST_URL)
+        sub = await client.create_subscription({
+            'name': 'test', 'source': 'test', 'hosting': 'yes, please'
+        })
+        assert sub['name'] == 'test'
+
+
 @pytest.mark.anyio
 @failing_api_mock
 async def test_cancel_subscription_failure():
@@ -236,24 +244,14 @@ async def test_update_subscription_failure():
 @pytest.mark.anyio
 @update_mock
 async def test_update_subscription_success():
-    """Subscription is updated, description has the expected items."""
+    """Subscription is created, description has the expected items."""
     async with Session() as session:
         client = SubscriptionsClient(session, base_url=TEST_URL)
         sub = await client.update_subscription(
             "test", {
-                "name": "test", "delivery": "no, thanks", "source": "test"
+                'name': 'test', 'delivery': "no, thanks", 'source': 'test'
             })
-        assert sub["delivery"] == "no, thanks"
-
-
-@pytest.mark.anyio
-@patch_mock
-async def test_patch_subscription_success():
-    """Subscription is patched, description has the expected items."""
-    async with Session() as session:
-        client = SubscriptionsClient(session, base_url=TEST_URL)
-        sub = await client.patch_subscription("test", {"name": "test patch"})
-        assert sub["name"] == "test patch"
+        assert sub['delivery'] == "no, thanks"
 
 
 @pytest.mark.anyio
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index 3ac230e..c296363 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -1,14 +1,13 @@
 """Tests of the Subscriptions CLI (aka planet-subscriptions)
 
-There are 7 subscriptions commands:
+There are 6 subscriptions commands:
 
-[x] planet subscriptions cancel
-[x] planet subscriptions create
-[x] planet subscriptions get
 [x] planet subscriptions list
-[x] planet subscriptions patch
+[x] planet subscriptions get
 [x] planet subscriptions results
+[x] planet subscriptions create
 [x] planet subscriptions update
+[x] planet subscriptions cancel
 
 TODO: tests for 3 options of the planet-subscriptions-results command.
 
@@ -22,13 +21,12 @@ import pytest
 from planet.cli import cli
 
 from test_subscriptions_api import (api_mock,
-                                    cancel_mock,
-                                    create_mock,
                                     failing_api_mock,
+                                    create_mock,
+                                    update_mock,
+                                    cancel_mock,
                                     get_mock,
-                                    patch_mock,
                                     res_api_mock,
-                                    update_mock,
                                     TEST_URL)
 
 # CliRunner doesn't agree with empty options, so a list of option
@@ -95,11 +93,16 @@ def test_subscriptions_create_failure(invoke):
 # It must be updated when we begin to test against a more strict
 # imitation of the Planet Subscriptions API.
 GOOD_SUB_REQUEST = {'name': 'lol', 'delivery': True, 'source': 'wut'}
+GOOD_SUB_REQUEST_WITH_HOSTING = {
+    'name': 'lol', 'source': 'wut', 'hosting': True
+}
 
 
 @pytest.mark.parametrize('cmd_arg, runner_input',
                          [('-', json.dumps(GOOD_SUB_REQUEST)),
-                          (json.dumps(GOOD_SUB_REQUEST), None)])
+                          (json.dumps(GOOD_SUB_REQUEST), None),
+                          ('-', json.dumps(GOOD_SUB_REQUEST_WITH_HOSTING)),
+                          (json.dumps(GOOD_SUB_REQUEST_WITH_HOSTING), None)])
 @create_mock
 def test_subscriptions_create_success(invoke, cmd_arg, runner_input):
     """Subscriptions creation succeeds with a valid subscription request."""
@@ -194,34 +197,6 @@ def test_subscriptions_update_success(invoke):
     assert json.loads(result.output)['name'] == 'new_name'
 
 
-@failing_api_mock
-def test_subscriptions_patch_failure(invoke):
-    """Patch command exits gracefully from an API error."""
-    result = invoke(
-        ['patch', 'test', json.dumps(GOOD_SUB_REQUEST)],
-        # 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 == 1  # failure.
-
-
-@patch_mock
-def test_subscriptions_patch_success(invoke):
-    """Patch command succeeds."""
-    request = {'name': 'test patch'}
-    result = invoke(
-        ['patch', 'test', json.dumps(request)],
-        # 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.
-    assert json.loads(result.output)['name'] == request['name']
-
-
 @failing_api_mock
 def test_subscriptions_get_failure(invoke):
     """Describe command exits gracefully from an API error."""
diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py
index ef4e434..2aa12f0 100644
--- a/tests/unit/test_subscription_request.py
+++ b/tests/unit/test_subscription_request.py
@@ -119,6 +119,59 @@ def test_build_request_clip_to_source_failure(geom_geojson):
         )
 
 
+def test_build_request_host_sentinel_hub_with_collection(geom_geojson):
+    source = {
+        "type": "catalog",
+        "parameters": {
+            "geometry": geom_geojson,
+            "start_time": "2021-03-01T00:00:00Z",
+            "end_time": "2023-11-01T00:00:00Z",
+            "rrule": "FREQ=MONTHLY;BYMONTH=3,4,5,6,7,8,9,10",
+            "item_types": ["PSScene"],
+            "asset_types": ["ortho_analytic_4b"]
+        }
+    }
+
+    hosting = {"type": "sentinel-hub"}
+
+    res = subscription_request.build_request('test',
+                                             source=source,
+                                             hosting=hosting)
+
+    expected = {"name": "test", "source": source, "hosting": hosting}
+
+    assert res == expected
+
+
+def test_build_request_host_sentinel_hub_no_collection(geom_geojson):
+    source = {
+        "type": "catalog",
+        "parameters": {
+            "geometry": geom_geojson,
+            "start_time": "2021-03-01T00:00:00Z",
+            "end_time": "2023-11-01T00:00:00Z",
+            "rrule": "FREQ=MONTHLY;BYMONTH=3,4,5,6,7,8,9,10",
+            "item_types": ["PSScene"],
+            "asset_types": ["ortho_analytic_4b"]
+        }
+    }
+
+    hosting = {
+        "type": "sentinel-hub",
+        "parameters": {
+            "collection_id": "4c9af036-4274-4a97-bf0d-eb2a7853330d"
+        }
+    }
+
+    res = subscription_request.build_request('test',
+                                             source=source,
+                                             hosting=hosting)
+
+    expected = {"name": "test", "source": source, "hosting": hosting}
+
+    assert res == expected
+
+
 def test_catalog_source_success(geom_geojson):
     res = subscription_request.catalog_source(
         item_types=["PSScene"],