Skip to content

Commit 7e8ffcc

Browse files
Resolve clip tool ordering in subscription requests (#1207)
* add smarter subscription tools validation and sorting * Update planet/subscription_request.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * update docstrings * lint and type check * harden validator * harden validators --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f139ac9 commit 7e8ffcc

File tree

4 files changed

+353
-7
lines changed

4 files changed

+353
-7
lines changed

planet/cli/data.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ def _func(obj):
172172
@click.option(
173173
"--geom-relation",
174174
type=click.Choice(["intersects", "contains", "within", "disjoint"]),
175-
help="""Geometry search relation. Options are intersects (default), contains, within, or disjoint.""",
176-
)
175+
help="""Geometry search relation. Options are intersects (default), contains,
176+
within, or disjoint.""")
177177
@click.option('--number-in',
178178
type=click.Tuple([types.Field(), types.CommaSeparatedFloat()]),
179179
multiple=True,

planet/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,14 @@
2323
PLANET_BASE_URL = 'https://api.planet.com'
2424

2525
SECRET_FILE_PATH = Path(os.path.expanduser('~')) / '.planet.json'
26+
27+
# Tool weights define the required processing order for subscription tools
28+
_SUBSCRIPTION_TOOL_WEIGHT = {
29+
"harmonize": 1,
30+
"toar": 2,
31+
"clip": 3,
32+
"reproject": 3,
33+
"bandmath": 3,
34+
"cloud_filter": 4,
35+
"file_format": 4,
36+
}

planet/subscription_request.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from . import geojson, specs
2222
from .clients.destinations import DEFAULT_DESTINATION_REF
23+
from .constants import _SUBSCRIPTION_TOOL_WEIGHT
2324
from .exceptions import ClientError
2425

2526
NOTIFICATIONS_TOPICS = ('delivery.success',
@@ -128,16 +129,17 @@ def build_request(name: str,
128129
if tools or clip_to_source:
129130
tool_list = [dict(tool) for tool in (tools or [])]
130131

131-
# If clip_to_source is True a clip configuration will be added
132-
# to the list of requested tools unless an existing clip tool
133-
# exists. In that case an exception is raised.
132+
# Validate that input tool_list is in correct order
133+
_validate_tool_order(tool_list)
134+
135+
# If clip_to_source is True, insert clip at correct position
134136
if clip_to_source:
135137
if any(tool.get('type', None) == 'clip' for tool in tool_list):
136138
raise ClientError(
137139
"clip_to_source option conflicts with a configured clip tool."
138140
)
139141
else:
140-
tool_list.append({'type': 'clip', 'parameters': {}})
142+
_insert_clip_tool(tool_list)
141143

142144
details['tools'] = tool_list
143145

@@ -837,3 +839,71 @@ def sentinel_hub(collection_id: Optional[str],
837839
if create_configuration:
838840
parameters['create_configuration'] = create_configuration
839841
return _hosting("sentinel_hub", parameters)
842+
843+
844+
def _validate_tool_order(tool_list: List[dict]) -> None:
845+
"""Validate that tools are ordered according to their processing weights.
846+
847+
Args:
848+
tool_list: List of tool configurations to validate.
849+
850+
Raises:
851+
ClientError: If tools are not in the correct order or if an invalid
852+
tool is encountered.
853+
"""
854+
for i in range(len(tool_list)):
855+
tool_type = tool_list[i].get('type')
856+
857+
# Check if tool has a type field
858+
if tool_type is None:
859+
raise ClientError(
860+
f"Tool at position {i} is missing required 'type' field.")
861+
862+
# Check if tool type is valid
863+
if tool_type not in _SUBSCRIPTION_TOOL_WEIGHT:
864+
raise ClientError(
865+
f"Invalid tool type '{tool_type}' at position {i}. "
866+
f"Valid types are: {', '.join(_SUBSCRIPTION_TOOL_WEIGHT.keys())}"
867+
)
868+
869+
# Validate ordering if not the first tool
870+
if i > 0:
871+
prev_type = tool_list[i - 1]['type']
872+
curr_weight = _SUBSCRIPTION_TOOL_WEIGHT[tool_type]
873+
prev_weight = _SUBSCRIPTION_TOOL_WEIGHT[prev_type]
874+
875+
if prev_weight > curr_weight:
876+
raise ClientError(
877+
f"Tools must be ordered according to their processing order. "
878+
f"Tool '{prev_type}' cannot come before tool '{tool_type}'."
879+
)
880+
881+
882+
def _insert_clip_tool(tool_list: List[dict]) -> None:
883+
"""Insert clip tool at the correct position in the tool list.
884+
885+
The clip tool is inserted based on its position relative to other tools in
886+
the _SUBSCRIPTION_TOOL_WEIGHT dictionary order (i.e. the order of the
887+
dictionary keys), not on the numeric weight values themselves. This means
888+
that the clip tool is placed before any tool whose key appears after
889+
"clip" in that dictionary.
890+
891+
Args:
892+
tool_list: List of tool configurations (modified in place).
893+
"""
894+
# Create a position mapping from the _SUBSCRIPTION_TOOL_WEIGHT dictionary
895+
tool_order = {
896+
name: idx
897+
for idx, name in enumerate(_SUBSCRIPTION_TOOL_WEIGHT.keys())
898+
}
899+
clip_position = tool_order['clip']
900+
insert_index = len(tool_list) # default to end
901+
902+
for i, tool in enumerate(tool_list):
903+
tool_type = tool.get('type')
904+
if tool_type in tool_order:
905+
if tool_order[tool_type] > clip_position:
906+
insert_index = i
907+
break
908+
909+
tool_list.insert(insert_index, {'type': 'clip', 'parameters': {}})

0 commit comments

Comments
 (0)