Repository: planetlabs/planet-client-python
Pull request #1098 (2025-02-24 20:20:40)

Add support for new subscriptions summary endpoints
**Related Issue(s):**

Closes #


**Proposed Changes:**

For inclusion in changelog (if applicable):

1. Support added for the two new subscription summary endpoints.

Not intended for changelog:

1. 

**Diff of User Interface**

Old behavior: N/A

New behavior:
CLI:
```
planet subscriptions summarize

planet subscriptions summarize --subscription-id={SUB_ID}
```
SDK:
```
from planet.sync import Planet
pl = Planet()
summary = pl.subscriptions.get_summary()

subscription_summary = pl.subscriptions.get_subscription_summary(SUB_ID)
```
**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/docs/cli/cli-subscriptions.md b/docs/cli/cli-subscriptions.md
index f8dab7b..4147d1d 100644
--- a/docs/cli/cli-subscriptions.md
+++ b/docs/cli/cli-subscriptions.md
@@ -555,4 +555,19 @@ planet subscriptions request \
 ```
 
 For more information on Sentinel Hub hosting, see the [Subscriptions API documentation](https://developers.planet.com/docs/subscriptions/delivery/#delivery-to-sentinel-hub-collection) and the [Linking Planet User to Sentinel Hub User
-](https://support.planet.com/hc/en-us/articles/16550358397469-Linking-Planet-User-to-Sentinel-Hub-User) support post.
\ No newline at end of file
+](https://support.planet.com/hc/en-us/articles/16550358397469-Linking-Planet-User-to-Sentinel-Hub-User) support post.
+
+
+#### Summaries
+
+You can get two types of summaries using the CLI.
+
+The first is a summary of all subscriptions created by the user, totaled by status.
+```sh
+planet subscriptions summarize
+```
+
+The second is a summary of _results_ for a specified subscription, totaled by status.
+```sh
+planet subscriptions summarize --subscription-id=cb817760-1f07-4ee7-bba6-bcac5346343f
+```
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index af25a89..4dfdcfe 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -526,3 +526,24 @@ def item_types(ctx):
     click.echo("Valid item types:")
     for it in get_item_types():
         click.echo(f"- {it}")
+
+
+@subscriptions.command()  # type: ignore
+@click.option(
+    '--subscription-id',
+    default=None,
+    help="""Optionally supply a subscription ID to summarize result counts
+    by status. If omitted, the summary will be generated for all
+    subscriptions the requester has created by status.""")
+@pretty
+@click.pass_context
+@translate_exceptions
+@coro
+async def summarize(ctx, subscription_id, pretty):
+    """Summarize the status of all subscriptions or the status of results for a single subscription"""
+    async with subscriptions_client(ctx) as client:
+        if subscription_id:
+            summary = await client.get_subscription_summary(subscription_id)
+        else:
+            summary = await client.get_summary()
+        echo_json(summary, pretty)
diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py
index ffce142..87a2960 100644
--- a/planet/clients/subscriptions.py
+++ b/planet/clients/subscriptions.py
@@ -407,3 +407,57 @@ class SubscriptionsClient:
         async with self._session.stream('GET', url, params=params) as response:
             async for line in response.aiter_lines():
                 yield line
+
+    async def get_summary(self) -> dict:
+        """Summarize the status of all subscriptions via GET.
+
+        Returns:
+            dict: subscription totals by status.
+
+        Raises:
+            APIError: on an API server error.
+            ClientError: on a client error.
+        """
+        url = f'{self._base_url}/summary'
+
+        try:
+            resp = await self._session.request(method='GET', url=url)
+        # 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:
+            summary = resp.json()
+            return summary
+
+    async def get_subscription_summary(
+        self,
+        subscription_id: str,
+    ) -> dict:
+        """Summarize the status of results for a single subscription via GET.
+
+        Args
+            subscription_id (str): ID of the subscription to summarize.
+
+        Returns:
+            dict: result totals for the provided subscription by status.
+
+        Raises:
+            APIError: on an API server error.
+            ClientError: on a client error.
+        """
+        url = f'{self._base_url}/{subscription_id}/summary'
+
+        try:
+            resp = await self._session.request(method='GET', url=url)
+        # 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:
+            summary = resp.json()
+            return summary
diff --git a/planet/sync/subscriptions.py b/planet/sync/subscriptions.py
index edab358..72e4615 100644
--- a/planet/sync/subscriptions.py
+++ b/planet/sync/subscriptions.py
@@ -280,3 +280,31 @@ class SubscriptionsAPI:
                 yield self._client._call_sync(results.__anext__())
         except StopAsyncIteration:
             pass
+
+    def get_summary(self) -> Dict[str, Any]:
+        """Summarize the status of all subscriptions via GET.
+
+        Returns:
+            dict: subscription totals by status.
+
+        Raises:
+            APIError: on an API server error.
+            ClientError: on a client error.
+        """
+        return self._client._call_sync(self._client.get_summary())
+
+    def get_subscription_summary(self, subscription_id: str) -> Dict[str, Any]:
+        """Summarize the status of results for a single subscription via GET.
+
+        Args
+            subscription_id (str): ID of the subscription to summarize.
+
+        Returns:
+            dict: result totals for the provided subscription by status.
+
+        Raises:
+            APIError: on an API server error.
+            ClientError: on a client error.
+        """
+        return self._client._call_sync(
+            self._client.get_subscription_summary(subscription_id))


Test patch:
diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py
index 6f17cdd..3f01d9c 100644
--- a/tests/integration/test_subscriptions_api.py
+++ b/tests/integration/test_subscriptions_api.py
@@ -146,6 +146,40 @@ get_mock.route(
                                                  'source': 'test'
                                              }))
 
+summary_mock = respx.mock()
+summary_mock.route(
+    M(url=f'{TEST_URL}/summary'),
+    method='GET').mock(return_value=Response(200,
+                                             json={
+                                                 "subscriptions": {
+                                                     "preparing": 0,
+                                                     "pending": 0,
+                                                     "running": 0,
+                                                     "completed": 0,
+                                                     "cancelled": 0,
+                                                     "suspended": 0,
+                                                     "failed": 0
+                                                 }
+                                             }))
+
+sub_summary_mock = respx.mock()
+sub_summary_mock.route(
+    M(url=f'{TEST_URL}/test/summary'),
+    method='GET').mock(return_value=Response(200,
+                                             json={
+                                                 "results": {
+                                                     "created": 0,
+                                                     "queued": 0,
+                                                     "processing": 0,
+                                                     "failed": 0,
+                                                     "cancelled": 0,
+                                                     "success": 0
+                                                 },
+                                                 "subscription": {
+                                                     "status": "pending"
+                                                 }
+                                             }))
+
 
 def result_pages(status=None, size=40):
     """Helper for creating fake result listing pages."""
@@ -526,3 +560,85 @@ async def test_list_subscriptions_cycle_break():
         async with Session() as session:
             client = SubscriptionsClient(session, base_url=TEST_URL)
             _ = [sub async for sub in client.list_subscriptions()]
+
+
+@pytest.mark.anyio
+@summary_mock
+async def test_get_summary():
+    """Summary fetched, has expected totals, async."""
+    async with Session() as session:
+        client = SubscriptionsClient(session, base_url=TEST_URL)
+        summary = await client.get_summary()
+        assert summary == {
+            "subscriptions": {
+                "preparing": 0,
+                "pending": 0,
+                "running": 0,
+                "completed": 0,
+                "cancelled": 0,
+                "suspended": 0,
+                "failed": 0
+            }
+        }
+
+
+@summary_mock
+def test_get_summary_sync():
+    """Summary fetched, has expected totals, sync."""
+    pl = Planet()
+    pl.subscriptions._client._base_url = TEST_URL
+    summary = pl.subscriptions.get_summary()
+    assert summary == {
+        "subscriptions": {
+            "preparing": 0,
+            "pending": 0,
+            "running": 0,
+            "completed": 0,
+            "cancelled": 0,
+            "suspended": 0,
+            "failed": 0
+        }
+    }
+
+
+@pytest.mark.anyio
+@sub_summary_mock
+async def test_get_sub_summary():
+    """Subscription summary fetched, has expected totals, async."""
+    async with Session() as session:
+        client = SubscriptionsClient(session, base_url=TEST_URL)
+        summary = await client.get_subscription_summary("test")
+        assert summary == {
+            "results": {
+                "created": 0,
+                "queued": 0,
+                "processing": 0,
+                "failed": 0,
+                "cancelled": 0,
+                "success": 0
+            },
+            "subscription": {
+                "status": "pending"
+            }
+        }
+
+
+@sub_summary_mock
+def test_get_sub_summary_sync():
+    """Subscription summary fetched, has expected totals, sync."""
+    pl = Planet()
+    pl.subscriptions._client._base_url = TEST_URL
+    summary = pl.subscriptions.get_subscription_summary("test")
+    assert summary == {
+        "results": {
+            "created": 0,
+            "queued": 0,
+            "processing": 0,
+            "failed": 0,
+            "cancelled": 0,
+            "success": 0
+        },
+        "subscription": {
+            "status": "pending"
+        }
+    }
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index c214826..7b071b2 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -29,6 +29,8 @@ from test_subscriptions_api import (api_mock,
                                     get_mock,
                                     patch_mock,
                                     res_api_mock,
+                                    sub_summary_mock,
+                                    summary_mock,
                                     update_mock,
                                     TEST_URL)
 
@@ -523,3 +525,43 @@ def test_item_types(invoke, mock_bundles):
     for item_type in expected_item_types:
         assert item_type in result.output
     assert result.exit_code == 0
+
+
+@summary_mock
+def test_summarize_subs(invoke):
+    result = invoke(['summarize'])
+
+    assert result.exit_code == 0  # success.
+    summary = json.loads(result.output)
+    assert summary == {
+        "subscriptions": {
+            "preparing": 0,
+            "pending": 0,
+            "running": 0,
+            "completed": 0,
+            "cancelled": 0,
+            "suspended": 0,
+            "failed": 0
+        }
+    }
+
+
+@sub_summary_mock
+def test_summarize_results(invoke):
+    result = invoke(['summarize', '--subscription-id=test'])
+
+    assert result.exit_code == 0  # success.
+    summary = json.loads(result.output)
+    assert summary == {
+        "results": {
+            "created": 0,
+            "queued": 0,
+            "processing": 0,
+            "failed": 0,
+            "cancelled": 0,
+            "success": 0
+        },
+        "subscription": {
+            "status": "pending"
+        }
+    }