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
316import json
4- from typing import List
17+ from typing import List , Optional
518from contextlib import asynccontextmanager
619
720import click
8- from planet import DataClient , Session
21+
22+ from planet import data_filter , exceptions , io , DataClient , Session
923
1024from .cmds import coro , translate_exceptions
1125from .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.' )
87366async 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.' )
120402async 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' )
143426async 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
0 commit comments