Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
97 changes: 72 additions & 25 deletions planet/subscription_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,13 +45,42 @@


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,
Copy link
Contributor Author

@sgillies sgillies Jun 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typing best practices (https://typing.readthedocs.io/en/latest/source/best_practices.html) is to accept mappings (most general) and return dicts (most specific). This follows Postel's Law.

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 (
Expand All @@ -72,36 +101,54 @@ 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)
Copy link
Contributor Author

@sgillies sgillies Jun 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the consequence of accepting mappings: we need to explicitly convert them to dicts here. The upside is that we will never modify dicts that users pass in to this function, which can cause subtle bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We copy information now, which has some overhead, but makes calling code more reliable. And building a subscriptions request doesn't need to be super optimized.

}

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


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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
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',
Expand Down
22 changes: 11 additions & 11 deletions tests/integration/test_data_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion tests/unit/test_subscription_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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

Expand Down