Skip to content

Commit 50c73a5

Browse files
authored
Merge pull request #594 from planetlabs/data-filter-cli-535
planet data filter CLI command with all options
2 parents e4f817c + fc2a0f1 commit 50c73a5

File tree

8 files changed

+712
-17
lines changed

8 files changed

+712
-17
lines changed

docs/guide.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,3 +481,33 @@ Example: `tools.json`
481481
]
482482
```
483483

484+
### Data API
485+
486+
Most `data` cli commands are simple wrappers around the
487+
[Planet Data API](https://developers.planet.com/docs/apis/data/reference/)
488+
commands with the only difference being the addition of functionality to create
489+
a search filter, activate an asset, poll for when activation is complete, and
490+
download the asset.
491+
492+
493+
#### Filter
494+
495+
The search-related Data API CLI commands require a search filter. The filter
496+
CLI command provides basic functionality for generating this filter. For
497+
more advanced functionality, use the Python API `data_filter` commands.
498+
499+
The following is an example of using the filter command to generate a filter
500+
that specifies an aquired date range and AOI:
501+
502+
```console
503+
$ planet data filter \
504+
--date-range acquired gte 2022-01-01 \
505+
--date-range acquired lt 2022-02-01 \
506+
--geom aoi.json
507+
```
508+
509+
This can be fed directly into a search command e.g.:
510+
511+
```console
512+
$ planet data filter --geom aoi.json | planet data search-quick PSScene -
513+
```

planet/cli/data.py

Lines changed: 291 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1+
# Copyright 2022 Planet Labs PBC.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4+
# use this file except in compliance with the License. You may obtain a copy of
5+
# the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations under
13+
# the License.
114
"""The Planet Data CLI."""
2-
15+
from datetime import datetime
316
import json
4-
from typing import List
17+
from typing import List, Optional
518
from contextlib import asynccontextmanager
619

720
import click
8-
from planet import DataClient, Session
21+
22+
from planet import data_filter, exceptions, io, DataClient, Session
923

1024
from .cmds import coro, translate_exceptions
1125
from .io import echo_json
@@ -66,7 +80,272 @@ def parse_filter(ctx, param, value: str) -> dict:
6680
return json_value
6781

6882

69-
# TODO: filter().
83+
def geom_to_filter(ctx, param, value: str) -> dict:
84+
if value is None:
85+
return value
86+
87+
geom = _parse_geom(ctx, param, value)
88+
return data_filter.geometry_filter(geom)
89+
90+
91+
def _parse_geom(ctx, param, value: str) -> dict:
92+
"""Turn geom JSON into a dict."""
93+
# read from raw json
94+
if value.startswith('{'):
95+
try:
96+
json_value = json.loads(value)
97+
except json.decoder.JSONDecodeError:
98+
raise click.BadParameter('geom does not contain valid json.',
99+
ctx=ctx,
100+
param=param)
101+
if json_value == {}:
102+
raise click.BadParameter('geom is empty.', ctx=ctx, param=param)
103+
# read from stdin or file
104+
else:
105+
try:
106+
with click.open_file(value) as f:
107+
json_value = json.load(f)
108+
except json.decoder.JSONDecodeError:
109+
raise click.BadParameter('geom does not contain valid json.',
110+
ctx=ctx,
111+
param=param)
112+
return json_value
113+
114+
115+
class FieldType(click.ParamType):
116+
"""Clarify that this entry is for a field"""
117+
name = 'field'
118+
119+
120+
class ComparisonType(click.ParamType):
121+
name = 'comp'
122+
valid = ['lt', 'lte', 'gt', 'gte']
123+
124+
def convert(self, value, param, ctx) -> str:
125+
if value not in self.valid:
126+
self.fail(f'COMP ({value}) must be one of {",".join(self.valid)}',
127+
param,
128+
ctx)
129+
return value
130+
131+
132+
class GTComparisonType(ComparisonType):
133+
"""Only support gt or gte comparison"""
134+
valid = ['gt', 'gte']
135+
136+
137+
class DateTimeType(click.ParamType):
138+
name = 'datetime'
139+
140+
def convert(self, value, param, ctx) -> datetime:
141+
if not isinstance(value, datetime):
142+
try:
143+
value = io.str_to_datetime(value)
144+
except exceptions.PlanetError as e:
145+
self.fail(str(e))
146+
147+
return value
148+
149+
150+
class CommaSeparatedString(click.types.StringParamType):
151+
"""A list of strings that is extracted from a comma-separated string."""
152+
153+
def convert(self, value, param, ctx) -> List[str]:
154+
value = super().convert(value, param, ctx)
155+
156+
if not isinstance(value, list):
157+
value = [part.strip() for part in value.split(",")]
158+
159+
return value
160+
161+
162+
class CommaSeparatedFloat(click.types.StringParamType):
163+
"""A list of floats that is extracted from a comma-separated string."""
164+
name = 'VALUE'
165+
166+
def convert(self, value, param, ctx) -> List[float]:
167+
values = CommaSeparatedString().convert(value, param, ctx)
168+
169+
try:
170+
ret = [float(v) for v in values]
171+
except ValueError:
172+
self.fail(f'Cound not convert all entries in {value} to float.')
173+
174+
return ret
175+
176+
177+
def assets_to_filter(ctx, param, assets: List[str]) -> Optional[dict]:
178+
# TODO: validate and normalize
179+
return data_filter.asset_filter(assets) if assets else None
180+
181+
182+
def date_range_to_filter(ctx, param, values) -> Optional[List[dict]]:
183+
184+
def _func(obj):
185+
field, comp, value = obj
186+
kwargs = {'field_name': field, comp: value}
187+
return data_filter.date_range_filter(**kwargs)
188+
189+
return [_func(v) for v in values] if values else None
190+
191+
192+
def range_to_filter(ctx, param, values) -> Optional[List[dict]]:
193+
194+
def _func(obj):
195+
field, comp, value = obj
196+
kwargs = {'field_name': field, comp: value}
197+
return data_filter.range_filter(**kwargs)
198+
199+
return [_func(v) for v in values] if values else None
200+
201+
202+
def update_to_filter(ctx, param, values) -> Optional[List[dict]]:
203+
204+
def _func(obj):
205+
field, comp, value = obj
206+
kwargs = {'field_name': field, comp: value}
207+
return data_filter.update_filter(**kwargs)
208+
209+
return [_func(v) for v in values] if values else None
210+
211+
212+
def number_in_to_filter(ctx, param, values) -> Optional[List[dict]]:
213+
214+
def _func(obj):
215+
field, values = obj
216+
return data_filter.number_in_filter(field_name=field, values=values)
217+
218+
return [_func(v) for v in values] if values else None
219+
220+
221+
def string_in_to_filter(ctx, param, values) -> Optional[List[dict]]:
222+
223+
def _func(obj):
224+
field, values = obj
225+
return data_filter.string_in_filter(field_name=field, values=values)
226+
227+
return [_func(v) for v in values] if values else None
228+
229+
230+
@data.command()
231+
@click.pass_context
232+
@translate_exceptions
233+
@pretty
234+
@click.option('--asset',
235+
type=CommaSeparatedString(),
236+
default=None,
237+
callback=assets_to_filter,
238+
help="""Filter to items with one or more of specified assets.
239+
VALUE is a comma-separated list of entries.
240+
When multiple entries are specified, an implicit 'or' logic is applied.""")
241+
@click.option('--date-range',
242+
type=click.Tuple([FieldType(), ComparisonType(),
243+
DateTimeType()]),
244+
callback=date_range_to_filter,
245+
multiple=True,
246+
help="""Filter by date range in field.
247+
FIELD is the name of the field to filter on.
248+
COMP can be lt, lte, gt, or gte.
249+
DATETIME can be an RFC3339 or ISO 8601 string.""")
250+
@click.option('--geom',
251+
type=str,
252+
default=None,
253+
callback=geom_to_filter,
254+
help='Filter to items that overlap a given geometry.')
255+
@click.option('--number-in',
256+
type=click.Tuple([FieldType(), CommaSeparatedFloat()]),
257+
multiple=True,
258+
callback=number_in_to_filter,
259+
help="""Filter field by numeric in.
260+
FIELD is the name of the field to filter on.
261+
VALUE is a comma-separated list of entries.
262+
When multiple entries are specified, an implicit 'or' logic is applied.""")
263+
@click.option('--range',
264+
'nrange',
265+
type=click.Tuple([FieldType(), ComparisonType(), float]),
266+
callback=range_to_filter,
267+
multiple=True,
268+
help="""Filter by date range in field.
269+
FIELD is the name of the field to filter on.
270+
COMP can be lt, lte, gt, or gte.
271+
DATETIME can be an RFC3339 or ISO 8601 string.""")
272+
@click.option('--string-in',
273+
type=click.Tuple([FieldType(), CommaSeparatedString()]),
274+
multiple=True,
275+
callback=string_in_to_filter,
276+
help="""Filter field by numeric in.
277+
FIELD is the name of the field to filter on.
278+
VALUE is a comma-separated list of entries.
279+
When multiple entries are specified, an implicit 'or' logic is applied.""")
280+
@click.option(
281+
'--update',
282+
type=click.Tuple([FieldType(), GTComparisonType(), DateTimeType()]),
283+
callback=update_to_filter,
284+
multiple=True,
285+
help="""Filter to items with changes to a specified field value made after
286+
a specified date.
287+
FIELD is the name of the field to filter on.
288+
COMP can be gt or gte.
289+
DATETIME can be an RFC3339 or ISO 8601 string.""")
290+
@click.option('--permission',
291+
type=bool,
292+
default=True,
293+
show_default=True,
294+
help='Filter to assets with download permissions.')
295+
@click.option('--std-quality',
296+
type=bool,
297+
default=True,
298+
show_default=True,
299+
help='Filter to standard quality.')
300+
def filter(ctx,
301+
asset,
302+
date_range,
303+
geom,
304+
number_in,
305+
nrange,
306+
string_in,
307+
update,
308+
permission,
309+
pretty,
310+
std_quality):
311+
"""Create a structured item search filter.
312+
313+
This command provides basic functionality for specifying a filter by
314+
creating an AndFilter with the filters identified with the options as
315+
inputs. This is only a subset of the complex filtering supported by the
316+
API. For advanced filter creation, either create the filter by hand or use
317+
the Python API.
318+
"""
319+
permission = data_filter.permission_filter() if permission else None
320+
std_quality = data_filter.std_quality_filter() if std_quality else None
321+
322+
filter_options = (asset,
323+
date_range,
324+
geom,
325+
number_in,
326+
nrange,
327+
string_in,
328+
update,
329+
permission,
330+
std_quality)
331+
332+
# options allowing multiples are broken up so one filter is created for
333+
# each time the option is specified
334+
# unspecified options are skipped
335+
filters = []
336+
for f in filter_options:
337+
if f:
338+
if isinstance(f, list):
339+
filters.extend(f)
340+
else:
341+
filters.append(f)
342+
343+
if filters:
344+
if len(filters) > 1:
345+
filt = data_filter.and_filter(filters)
346+
else:
347+
filt = filters[0]
348+
echo_json(filt, pretty)
70349

71350

72351
@data.command()
@@ -85,8 +364,11 @@ def parse_filter(ctx, param, value: str) -> dict:
85364
default=100,
86365
help='Maximum number of results to return. Defaults to 100.')
87366
async def search_quick(ctx, item_types, filter, name, limit, pretty):
88-
"""This function executes a structured item search using the item_types,
367+
"""Execute a structured item search.
368+
369+
This function executes a structured item search using the item_types,
89370
and json filter specified (using file or stdin).
371+
90372
Quick searches are stored for approximately 30 days and the --name
91373
parameter will be applied to the stored quick search. This function
92374
outputs a series of GeoJSON descriptions, one for each of the returned
@@ -118,13 +400,14 @@ async def search_quick(ctx, item_types, filter, name, limit, pretty):
118400
is_flag=True,
119401
help='Send a daily email when new results are added.')
120402
async def search_create(ctx, name, item_types, filter, daily_email, pretty):
121-
""" This function creates a new saved structured item search, using the
403+
"""Create a new saved structured item search.
404+
405+
This function creates a new saved structured item search, using the
122406
name of the search, item_types, and json filter specified (using file or
123407
stdin). If specified, the "--daily_email" option enables users to recieve
124408
an email when new results are available each day. This function outputs a
125409
full JSON description of the created search. The output can also be
126410
optionally pretty-printed using "--pretty".
127-
128411
"""
129412
async with data_client(ctx) as cl:
130413
items = await cl.create_search(name=name,
@@ -141,7 +424,7 @@ async def search_create(ctx, name, item_types, filter, daily_email, pretty):
141424
@pretty
142425
@click.argument('search_id')
143426
async def search_get(ctx, search_id, pretty):
144-
"""Get saved search.
427+
"""Get a saved search.
145428
146429
This function obtains an existing saved search, using the search_id.
147430
This function outputs a full JSON description of the identified saved

planet/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
import os
1717
from pathlib import Path
1818

19+
# NOTE: entries are given in alphabetical order
20+
1921
DATA_DIR = Path(os.path.dirname(__file__)) / 'data'
2022

2123
PLANET_BASE_URL = 'https://api.planet.com'

0 commit comments

Comments
 (0)