Repository: planetlabs/planet-client-python

Issue:
Reuse subscription source geometry in clip tool
Specifying a clip geometry will be optional. If one is provided, it will be ignored in favor of the subscription source geometry.

Discussion:

Patch:
diff --git a/CHANGES.txt b/CHANGES.txt
index e0f441e..44c93cc 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,3 +1,10 @@
+2.1.0 (TBD)
+
+Added:
+- The subscription_request.build_request function has a new option to clip to
+  the subscription's source geometry. This is a preview of the default
+  behavior of the next version of the Subscriptions API.
+
 2.0.3 (2023-06-28)
 
 Changed:
diff --git a/planet/subscription_request.py b/planet/subscription_request.py
index 6d26d7b..391f168 100644
--- a/planet/subscription_request.py
+++ b/planet/subscription_request.py
@@ -13,7 +13,7 @@
 # the License.
 """Functionality for preparing subscription requests."""
 from datetime import datetime
-from typing import Any, Dict, Optional, List
+from typing import Any, Dict, Optional, List, Mapping
 
 from . import geojson, specs
 from .exceptions import ClientError
@@ -45,13 +45,42 @@ REPROJECT_KERNEL_DEFAULT = 'near'
 
 
 def build_request(name: str,
-                  source: dict,
-                  delivery: dict,
-                  notifications: Optional[dict] = None,
-                  tools: Optional[List[dict]] = None) -> dict:
-    """Prepare a subscriptions request.
+                  source: Mapping,
+                  delivery: Mapping,
+                  notifications: Optional[Mapping] = None,
+                  tools: Optional[List[Mapping]] = None,
+                  clip_to_source=False) -> dict:
+    """Construct a Subscriptions API request.
 
+    The return value can be passed to
+    [planet.clients.subscriptions.SubscriptionsClient.create_subscription][].
 
+    Parameters:
+        name: Name of the subscription.
+        source: A source for the subscription, i.e. catalog.
+        delivery: A delivery mechanism e.g. GCS, AWS, Azure, or OCS.
+        notifications: Specify notifications via email/webhook.
+        tools: Tools to apply to the products. The order of operation
+            is determined by the service.
+        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
+            exists.  NOTE: Not all data layers support clipping, please
+            consult the Product reference before using this option.
+            NOTE: the next version of the Subscription API will remove
+            the clip tool option and always clip to the source geometry.
+            Thus this is a preview of the next API version's default
+            behavior.
+
+    Returns:
+        A Python dict representation of a Subscriptions API request for
+        a new subscription.
+
+    Raises:
+        ClientError when a valid Subscriptions API request can't be
+        constructed.
+
+    Examples:
     ```python
     >>> from datetime import datetime
     >>> from planet.subscription_request import (
@@ -72,26 +101,44 @@ def build_request(name: str,
     ...     ACCESS_KEY_ID, SECRET_ACCESS_KEY, "test", "us-east-1")
     ...
     >>> subscription_request = build_request(
-    ...     'test_subscription', source, delivery)
+    ...     'test_subscription', source=source, delivery=delivery)
     ...
 
     ```
-
-    Parameters:
-        name: Name of the subscription.
-        source: A source for the subscription, i.e. catalog.
-        delivery: A delivery mechanism e.g. GCS, AWS, Azure, or OCS.
-        notifications: Specify notifications via email/webhook.
-        tools: Tools to apply to the products. Order defines
-            the toolchain order of operatations.
     """
-    details = {"name": name, "source": source, "delivery": delivery}
+    # Because source and delivery are Mappings 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)
+    }
 
     if notifications:
-        details['notifications'] = notifications
+        details['notifications'] = dict(notifications)
 
     if tools:
-        details['tools'] = tools
+        tool_list = [dict(tool) for tool in tools]
+
+        # If clip_to_source is True a clip configuration will be added
+        # to the list of requested tools unless an existing clip tool
+        # exists. In that case an exception is raised. NOTE: the next
+        # version of the Subscription API will remove the clip tool
+        # option and always clip to the source geometry. Thus this is a
+        # preview of the next API version's default behavior.
+        if clip_to_source:
+            if any(tool.get('type', None) == 'clip' for tool in tool_list):
+                raise ClientError(
+                    "clip_to_source option conflicts with a configured clip tool."
+                )
+            else:
+                tool_list.append({
+                    'type': 'clip',
+                    'parameters': {
+                        'aoi': source['parameters']['geometry']
+                    }
+                })
+
+        details['tools'] = tool_list
 
     return details
 
@@ -99,9 +146,9 @@ def build_request(name: str,
 def catalog_source(
     item_types: List[str],
     asset_types: List[str],
-    geometry: dict,
+    geometry: Mapping,
     start_time: datetime,
-    filter: Optional[dict] = None,
+    filter: Optional[Mapping] = None,
     end_time: Optional[datetime] = None,
     rrule: Optional[str] = None,
 ) -> dict:
@@ -142,7 +189,7 @@ def catalog_source(
     parameters = {
         "item_types": item_types,
         "asset_types": asset_types,
-        "geometry": geojson.as_geom(geometry),
+        "geometry": geojson.as_geom(dict(geometry)),
     }
 
     try:
@@ -151,7 +198,7 @@ def catalog_source(
         raise ClientError('Could not convert start_time to an iso string')
 
     if filter:
-        parameters['filter'] = filter
+        parameters['filter'] = dict(filter)
 
     if end_time:
         try:
@@ -348,7 +395,7 @@ def band_math_tool(b1: str,
     return _tool('bandmath', parameters)
 
 
-def clip_tool(aoi: dict) -> dict:
+def clip_tool(aoi: Mapping) -> dict:
     """Specify a subscriptions API clip tool.
 
     Imagery and udm files will be clipped to your area of interest. nodata
@@ -370,12 +417,12 @@ def clip_tool(aoi: dict) -> dict:
     """
     valid_types = ['Polygon', 'MultiPolygon']
 
-    geom = geojson.as_geom(aoi)
+    geom = geojson.as_geom(dict(aoi))
     if geom['type'].lower() not in [v.lower() for v in valid_types]:
         raise ClientError(
             f'Invalid geometry type: {geom["type"]} is not in {valid_types}.')
 
-    return _tool('clip', {'aoi': aoi})
+    return _tool('clip', {'aoi': geom})
 
 
 def file_format_tool(file_format: str) -> dict:
diff --git a/setup.cfg b/setup.cfg
index b9e0783..e24c32d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -17,3 +17,6 @@ fail_under = 98
 [yapf]
 based_on_style = pep8
 split_all_top_level_comma_separated_values=true
+
+[flake8]
+ignore = E126,E501,W50
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 656323c..c7f0b69 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,9 @@ with open('planet/__version__.py') as f:
             continue
 
 install_requires = [
-    'click>=8.0.0',
+    # click 8.1.4 breaks our mypy check, see
+    # https://github.com/pallets/click/issues/2558.
+    'click>8.0,<8.1.4',
     'geojson',
     'httpx>=0.23.0',
     'jsonschema',


Test patch:
diff --git a/tests/integration/test_data_api.py b/tests/integration/test_data_api.py
index a0d8d5d..7a57f53 100644
--- a/tests/integration/test_data_api.py
+++ b/tests/integration/test_data_api.py
@@ -558,17 +558,17 @@ async def test_run_search_doesnotexist(session):
 async def test_get_stats_success(search_filter, session):
 
     page_response = {
-        "buckets": [{
-            "count": 433638, "start_time": "2022-01-01T00:00:00.000000Z"
-        },
-                    {
-                        "count": 431924,
-                        "start_time": "2022-01-02T00:00:00.000000Z"
-                    },
-                    {
-                        "count": 417138,
-                        "start_time": "2022-01-03T00:00:00.000000Z"
-                    }]
+        "buckets": [
+            {
+                "count": 433638, "start_time": "2022-01-01T00:00:00.000000Z"
+            },
+            {
+                "count": 431924, "start_time": "2022-01-02T00:00:00.000000Z"
+            },
+            {
+                "count": 417138, "start_time": "2022-01-03T00:00:00.000000Z"
+            },
+        ],
     }
     mock_resp = httpx.Response(HTTPStatus.OK, json=page_response)
     respx.post(TEST_STATS_URL).return_value = mock_resp
diff --git a/tests/unit/test_subscription_request.py b/tests/unit/test_subscription_request.py
index 534bc29..b22983b 100644
--- a/tests/unit/test_subscription_request.py
+++ b/tests/unit/test_subscription_request.py
@@ -65,6 +65,59 @@ def test_build_request_success(geom_geojson):
     assert res == expected
 
 
+def test_build_request_clip_to_source_success(geom_geojson):
+    """Without a clip tool we can clip to source."""
+    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"]
+        }
+    }
+    req = subscription_request.build_request(
+        'test',
+        source=source,
+        delivery={},
+        tools=[{
+            'type': 'hammer'
+        }],
+        clip_to_source=True,
+    )
+    assert req["tools"][1]["type"] == "clip"
+    assert req["tools"][1]["parameters"]["aoi"] == geom_geojson
+
+
+def test_build_request_clip_to_source_failure(geom_geojson):
+    """With a clip tool we can not clip to source."""
+    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"]
+        }
+    }
+    with pytest.raises(exceptions.ClientError):
+        subscription_request.build_request(
+            'test',
+            source=source,
+            delivery={},
+            tools=[{
+                'type': 'clip'
+            }, {
+                'type': 'hammer'
+            }],
+            clip_to_source=True,
+        )
+
+
 def test_catalog_source_success(geom_geojson):
     res = subscription_request.catalog_source(
         item_types=["PSScene"],
@@ -230,7 +283,6 @@ def test_band_math_tool_invalid_pixel_type():
 
 def test_clip_tool_success(geom_geojson):
     res = subscription_request.clip_tool(geom_geojson)
-
     expected = {"type": "clip", "parameters": {"aoi": geom_geojson}}
     assert res == expected