Repository: planetlabs/planet-client-python
Pull request #1128 (2025-05-07 01:42:14)

Update main-3.0-dev from main
Merge of main into main 3.0 dev branch


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c2ea51a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,51 @@
+name: Release to GitHub and PyPI
+
+on:
+  workflow_dispatch:
+    inputs:
+      prerelease:
+        description: 'Is this a pre-release?'
+        required: true
+        type: boolean
+
+jobs:
+  package:
+    name: Build, verify, & upload package
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v3
+        with:
+          python-version: "3.12"
+
+      - name: Create GitHub Release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions.
+        with:
+          tag_name: ${{ github.ref }}
+          release_name: ${{ github.ref }}
+          draft: false
+          prerelease: ${{ inputs.logLevel }}
+
+      - run: python -m pip install build twine check-wheel-contents
+      - run: python -m build --sdist --wheel .
+      - run: ls -l dist
+      - run: check-wheel-contents dist/*.whl
+
+      - name: Check long_description
+        run: python -m twine check dist/*
+
+      - name: Upload to TestPyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.2
+        with:
+          repository_url: https://test.pypi.org/legacy/
+          user: __token__
+          password: ${{ secrets.TEST_PYPI_PASSWORD }}
+
+      - name: Upload to PyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.2
+        with:
+          user: __token__
+          password: ${{ secrets.PYPI_PASSWORD }}
\ No newline at end of file
diff --git a/RELEASE-PLANNING-3.0.md b/RELEASE-PLANNING-3.0.md
deleted file mode 100644
index 796a75f..0000000
--- a/RELEASE-PLANNING-3.0.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Planet Python Client 3.0 Release Planning
-
-* Authentication changes:
-  * Migrate to OAuth2 based authentication mechanisms, leveraging the
-    [planet-auth-python](https://github.com/planetlabs/planet-auth-python)
-    library for implementation.
-  * Deprecate use of the legacy authentication protocol and handling of the
-    user's password.
-  * CLI changes to support changes in authentication practices.
-  * Support for API keys supplied by the user is maintained, but users should
-    be aware that there are currently no plans for sentinel-hub.com APIs to
-    support Planet API keys.  The longer term roadmap is for all APIs to work
-    with OAuth service accounts.
diff --git a/RELEASE.md b/RELEASE.md
index d94bb1b..b0aedbd 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -54,6 +54,7 @@ The SDK follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and t
      * _`version`_: [https://planet-sdk-for-python-v2.readthedocs.io/en/X.YY.ZZ/](https://planet-sdk-for-python-v2.readthedocs.io/en/X.YY.ZZ/) - Should point to version `X.YY.ZZ`.
    * Pre-release versions should _not_ impact the default version of the documentation. Pre-release version may be published as the `latest` version.
 
+
 ## Local publishing
 
 Publishing to testpypi and pypi can also be performed locally with:
diff --git a/docs/cli/cli-subscriptions.md b/docs/cli/cli-subscriptions.md
index 0e111e2..f7c5518 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:
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index 4cc65c3..3d6bb16 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -203,6 +203,60 @@ async def create_subscription_cmd(ctx, request, pretty, **kwargs):
         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
 @click.argument('subscription_id')
 @pretty
diff --git a/planet/clients/subscriptions.py b/planet/clients/subscriptions.py
index df1638c..0638ec1 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 @@ class SubscriptionsClient:
             sub = resp.json()
             return sub
 
+    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.
+
+        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:
+            The response including a _links key to the list endpoint for use finding the created subscriptions.
+        """
+        try:
+            url = f'{self._base_url}/bulk'
+            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 72e4615..f22f899 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 @@ class SubscriptionsAPI:
         return self._client._call_sync(
             self._client.create_subscription(request))
 
+    def bulk_create_subscriptions(self, requests: List[Dict]) -> Dict:
+        """Bulk create subscriptions.
+
+        Args:
+            request (List[dict]): list of descriptions of a bulk creation.
+
+        Returns:
+            response including link to list of created subscriptions
+
+        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.
 


Test patch:
diff --git a/tests/integration/test_subscriptions_api.py b/tests/integration/test_subscriptions_api.py
index 23d99d3..25b299d 100644
--- a/tests/integration/test_subscriptions_api.py
+++ b/tests/integration/test_subscriptions_api.py
@@ -122,6 +122,17 @@ create_mock = respx.mock()
 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."""
@@ -346,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():
diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py
index dcdaf29..b911f7d 100644
--- a/tests/integration/test_subscriptions_cli.py
+++ b/tests/integration/test_subscriptions_cli.py
@@ -25,6 +25,7 @@ from planet.cli import cli
 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"}'