Repository: planetlabs/planet-client-python
Pull request #1023 (2024-02-08 12:29:59)

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/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index b3bfe2b..70b7dad 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -249,7 +249,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 +261,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 +276,7 @@ def request(name,
             delivery,
             notifications,
             tools,
+            hosting,
             clip_to_source,
             pretty):
     """Generate a subscriptions request.
@@ -287,6 +291,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/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..7fbc9ba 100644
--- a/tests/integration/test_subscriptions_api.py
+++ b/tests/integration/test_subscriptions_api.py
@@ -204,6 +204,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():
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index 3ac230e..25b462d 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -95,11 +95,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."""
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"],