Skip to content

Commit b29af00

Browse files
Merge pull request #1128 from planetlabs/main
Update main-3.0-dev from main
2 parents c721967 + 1b12fb8 commit b29af00

File tree

6 files changed

+201
-2
lines changed

6 files changed

+201
-2
lines changed

docs/cli/cli-subscriptions.md

+43
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,49 @@ planet subscriptions patch cb817760-1f07-4ee7-bba6-bcac5346343f \
247247
patched-attributes.json
248248
```
249249

250+
### Bulk Create Subscriptions
251+
252+
To create many subscriptions for different geometries at once, use the `bulk-create` subcommand.
253+
254+
This command allows submitting a bulk create request that references a feature collection defined in
255+
the Features API, which will create a subscription for every feature in the collection.
256+
257+
Define a subscription that references a feature collection:
258+
259+
```json
260+
{
261+
"name": "new guinea psscene bulk subscription",
262+
"source": {
263+
"parameters": {
264+
"item_types": [
265+
"PSScene"
266+
],
267+
"asset_types": [
268+
"ortho_visual"
269+
],
270+
"geometry": {
271+
"type": "ref",
272+
"content": "pl:features/my/test-new-guinea-10geojson-xqRXaaZ"
273+
},
274+
"start_time": "2021-01-01T00:00:00Z",
275+
"end_time": "2021-01-05T00:00:00Z"
276+
}
277+
}
278+
}
279+
```
280+
281+
And issue the `bulk-create` command with the appropriate hosting or delivery options. A link to list
282+
the resulting subscriptions will be returned:
283+
284+
```sh
285+
planet subscriptions bulk-create --hosting sentinel_hub catalog_fc_sub.json
286+
{
287+
"_links": {
288+
"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"
289+
}
290+
}
291+
```
292+
250293
### Cancel Subscription
251294

252295
Cancelling a subscription is simple with the CLI:

planet/cli/subscriptions.py

+54
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,60 @@ async def create_subscription_cmd(ctx, request, pretty, **kwargs):
203203
echo_json(sub, pretty)
204204

205205

206+
@subscriptions.command(name="bulk-create") # type: ignore
207+
@click.argument("request", type=types.JSON())
208+
@click.option(
209+
"--hosting",
210+
type=click.Choice([
211+
"sentinel_hub",
212+
]),
213+
default=None,
214+
help='Hosting type. Currently, only "sentinel_hub" is supported.',
215+
)
216+
@click.option("--collection-id",
217+
default=None,
218+
help='Collection ID for Sentinel Hub hosting. '
219+
'If omitted, a new collection will be created.')
220+
@click.option(
221+
'--create-configuration',
222+
is_flag=True,
223+
help='Automatically create a layer configuration for your collection. '
224+
'If omitted, no configuration will be created.')
225+
@pretty
226+
@click.pass_context
227+
@translate_exceptions
228+
@coro
229+
async def bulk_create_subscription_cmd(ctx, request, pretty, **kwargs):
230+
"""Bulk create subscriptions.
231+
232+
Submits a bulk subscription request for creation and prints a link to list
233+
the resulting subscriptions.
234+
235+
REQUEST is the full description of the subscription to be created. It must
236+
be JSON and can be specified a json string, filename, or '-' for stdin.
237+
238+
Other flag options are hosting, collection_id, and create_configuration.
239+
The hosting flag specifies the hosting type, the collection_id flag specifies the
240+
collection ID for Sentinel Hub, and the create_configuration flag specifies
241+
whether or not to create a layer configuration for your collection. If the
242+
collection_id is omitted, a new collection will be created. If the
243+
create_configuration flag is omitted, no configuration will be created. The
244+
collection_id flag and create_configuration flag cannot be used together.
245+
"""
246+
hosting = kwargs.get("hosting", None)
247+
collection_id = kwargs.get("collection_id", None)
248+
create_configuration = kwargs.get('create_configuration', False)
249+
250+
if hosting == "sentinel_hub":
251+
hosting_info = sentinel_hub(collection_id, create_configuration)
252+
request["hosting"] = hosting_info
253+
254+
async with subscriptions_client(ctx) as client:
255+
links = await client.bulk_create_subscriptions([request])
256+
# Bulk create returns just a link to an endpoint to list created subscriptions.
257+
echo_json(links, pretty)
258+
259+
206260
@subscriptions.command(name='cancel') # type: ignore
207261
@click.argument('subscription_id')
208262
@pretty

planet/clients/subscriptions.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Planet Subscriptions API Python client."""
22

33
import logging
4-
from typing import Any, AsyncIterator, Awaitable, Dict, Optional, Sequence, TypeVar
4+
from typing import Any, AsyncIterator, Awaitable, Dict, Optional, Sequence, TypeVar, List
55

66
from typing_extensions import Literal
77

@@ -203,6 +203,34 @@ async def create_subscription(self, request: dict) -> dict:
203203
sub = resp.json()
204204
return sub
205205

206+
async def bulk_create_subscriptions(self, requests: List[dict]) -> Dict:
207+
"""
208+
Create multiple subscriptions in bulk. Currently, the list of requests can only contain one item.
209+
210+
Args:
211+
requests (List[dict]): A list of dictionaries where each dictionary
212+
represents a subscription to be created.
213+
214+
Raises:
215+
APIError: If the API returns an error response.
216+
ClientError: If there is an issue with the client request.
217+
218+
Returns:
219+
The response including a _links key to the list endpoint for use finding the created subscriptions.
220+
"""
221+
try:
222+
url = f'{self._base_url}/bulk'
223+
resp = await self._session.request(
224+
method='POST', url=url, json={'subscriptions': requests})
225+
# Forward APIError. We don't strictly need this clause, but it
226+
# makes our intent clear.
227+
except APIError:
228+
raise
229+
except ClientError: # pragma: no cover
230+
raise
231+
else:
232+
return resp.json()
233+
206234
async def cancel_subscription(self, subscription_id: str) -> None:
207235
"""Cancel a Subscription.
208236

planet/sync/subscriptions.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Planet Subscriptions API Python client."""
22

3-
from typing import Any, Dict, Iterator, Optional, Sequence, Union
3+
from typing import Any, Dict, Iterator, Optional, Sequence, Union, List
44

55
from typing_extensions import Literal
66

@@ -136,6 +136,22 @@ def create_subscription(self, request: Dict) -> Dict:
136136
return self._client._call_sync(
137137
self._client.create_subscription(request))
138138

139+
def bulk_create_subscriptions(self, requests: List[Dict]) -> Dict:
140+
"""Bulk create subscriptions.
141+
142+
Args:
143+
request (List[dict]): list of descriptions of a bulk creation.
144+
145+
Returns:
146+
response including link to list of created subscriptions
147+
148+
Raises:
149+
APIError: on an API server error.
150+
ClientError: on a client error.
151+
"""
152+
return self._client._call_sync(
153+
self._client.bulk_create_subscriptions(requests))
154+
139155
def cancel_subscription(self, subscription_id: str) -> None:
140156
"""Cancel a Subscription.
141157

tests/integration/test_subscriptions_api.py

+35
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ def modify_response(request):
122122
create_mock.route(M(url=TEST_URL),
123123
method='POST').mock(side_effect=modify_response)
124124

125+
bulk_create_mock = respx.mock()
126+
bulk_create_mock.route(
127+
M(url=f'{TEST_URL}/bulk'), method='POST'
128+
).mock(return_value=Response(
129+
200,
130+
json={
131+
'_links': {
132+
'list': f'{TEST_URL}/subscriptions/v1?created={datetime.now().isoformat()}/&geom_ref=pl:features:test_features&name=test-sub'
133+
}
134+
}))
135+
125136
update_mock = respx.mock()
126137
update_mock.route(M(url=f'{TEST_URL}/test'),
127138
method='PUT').mock(side_effect=modify_response)
@@ -334,6 +345,18 @@ async def test_create_subscription_success():
334345
assert sub['name'] == 'test'
335346

336347

348+
@pytest.mark.anyio
349+
@bulk_create_mock
350+
async def test_bulk_create_subscription_success():
351+
"""Bulk subscription is created, description has the expected items."""
352+
async with Session() as session:
353+
client = SubscriptionsClient(session, base_url=TEST_URL)
354+
resp = await client.bulk_create_subscriptions([{
355+
'name': 'test', 'delivery': 'yes, please', 'source': 'test'
356+
}])
357+
assert '/subscriptions/v1?' in resp['_links']['list']
358+
359+
337360
@create_mock
338361
def test_create_subscription_success_sync():
339362
"""Subscription is created, description has the expected items."""
@@ -346,6 +369,18 @@ def test_create_subscription_success_sync():
346369
assert sub['name'] == 'test'
347370

348371

372+
@bulk_create_mock
373+
def test_bulk_create_subscription_success_sync():
374+
"""Subscription is created, description has the expected items."""
375+
376+
pl = Planet()
377+
pl.subscriptions._client._base_url = TEST_URL
378+
resp = pl.subscriptions.bulk_create_subscriptions([{
379+
'name': 'test', 'delivery': 'yes, please', 'source': 'test'
380+
}])
381+
assert '/subscriptions/v1?' in resp['_links']['list']
382+
383+
349384
@pytest.mark.anyio
350385
@create_mock
351386
async def test_create_subscription_with_hosting_success():

tests/integration/test_subscriptions_cli.py

+23
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from test_subscriptions_api import (api_mock,
2626
cancel_mock,
2727
create_mock,
28+
bulk_create_mock,
2829
failing_api_mock,
2930
get_mock,
3031
patch_mock,
@@ -138,6 +139,28 @@ def test_subscriptions_create_success(invoke, cmd_arg, runner_input):
138139
assert result.exit_code == 0 # success.
139140

140141

142+
@pytest.mark.parametrize('cmd_arg, runner_input',
143+
[('-', json.dumps(GOOD_SUB_REQUEST)),
144+
(json.dumps(GOOD_SUB_REQUEST), None),
145+
('-', json.dumps(GOOD_SUB_REQUEST_WITH_HOSTING)),
146+
(json.dumps(GOOD_SUB_REQUEST_WITH_HOSTING), None)])
147+
@bulk_create_mock
148+
def test_subscriptions_bulk_create_success(invoke, cmd_arg, runner_input):
149+
"""Subscriptions creation succeeds with a valid subscription request."""
150+
151+
# The "-" argument says "read from stdin" and the input keyword
152+
# argument specifies what bytes go to the runner's stdin.
153+
result = invoke(
154+
['bulk-create', cmd_arg],
155+
input=runner_input,
156+
# Note: catch_exceptions=True (the default) is required if we want
157+
# to exercise the "translate_exceptions" decorator and test for
158+
# failure.
159+
catch_exceptions=True)
160+
161+
assert result.exit_code == 0 # success.
162+
163+
141164
# Invalid JSON.
142165
BAD_SUB_REQUEST = '{0: "lolwut"}'
143166

0 commit comments

Comments
 (0)