diff --git a/docs/source/cli/examples.rst b/docs/source/cli/examples.rst index b5759b1be..903e37ad7 100644 --- a/docs/source/cli/examples.rst +++ b/docs/source/cli/examples.rst @@ -61,7 +61,7 @@ meant to be easily interoperable with other tools, e.g. `jq `_. For example, we could output just the name and date range of each mosaic with:: - planet mosaics list | jq -r '.mosaics[] | [.name, .first_acquired, .last_acquired] | @tsv' + planet mosaics list | jq -r '.mosaics[] | [.name, .first_acquired, .last_acquired] | @tsv' Get basic information for a specific mosaic:: @@ -73,7 +73,7 @@ list all quads. Keep in mind that there may be millions for a global mosaic.):: planet mosaics search global_monthly_2018_09_mosaic --limit=10 Find all quads inside a particular area of interest:: - + planet mosaics search global_monthly_2018_09_mosaic --bbox=-95.5,29.6,-95.3,29.8 Note that the format of ``--bbox`` is "xmin,ymin,xmax,ymax", so longitude comes @@ -193,7 +193,7 @@ Orders Examples ----------------- List all recent orders for the authenticated user:: - + planet orders list Get the status of a single order by Order ID:: @@ -203,17 +203,17 @@ Get the status of a single order by Order ID:: Note that you may want to parse the JSON that's output into a more human readable format. The cli does not directly provide options for this, but is meant to be easily interoperable with other tools, e.g. `jq -`_. +`_. To cancel a running order by given order ID:: planet orders cancel -To download an order to your local machine:: +To download an order to your local machine:: - planet orders download + planet orders download -Optionally, a `--dest ` flag may be specified too. +Optionally, a `--dest ` flag may be specified too. Creating an Order .................. @@ -227,19 +227,148 @@ The minimal command to create a simple order looks something like:: If no toolchain or delivery details are specified, a basic order with download delivery will be placed for the requested bundle including the item id(s) specified. -Additionally, optional toolchain & delivery details can be provided on the -command line, e.g.::: +In the place of `--id`, you can insert a Data search string. This will populate +the list of IDs from a search. For example:: + + planet orders create --name "my order" \ + --ids_from_search $'--item-type PSScene3Band --date acquired gt 2017-02-14 --date acquired lt 2017-03-14 --limit 6 --geom \'{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -116.40701293945311, + 43.061363052307875 + ], + [ + -116.4451217651367, + 43.05032512283074 + ], + [ + -116.4320755004883, + 43.017450433440814 + ], + [ + -116.37508392333984, + 43.01092359150748 + ], + [ + -116.3393783569336, + 43.03677585761058 + ], + [ + -116.35894775390624, + 43.06186472916744 + ], + [ + -116.40701293945311, + 43.061363052307875 + ] + ] + ] + } + } + ] + }\'' \ + --bundle visual \ + --item-type psscene3band \ + --zip bundle --email \ + --clip '{ + "type": "Polygon", + "coordinates": [ + [ + [ + -116.40701293945311, + 43.061363052307875 + ], + [ + -116.4451217651367, + 43.05032512283074 + ], + [ + -116.4320755004883, + 43.017450433440814 + ], + [ + -116.37508392333984, + 43.01092359150748 + ], + [ + -116.3393783569336, + 43.03677585761058 + ], + [ + -116.35894775390624, + 43.06186472916744 + ], + [ + -116.40701293945311, + 43.061363052307875 + ] + ] + ] + }' + +Note that `--ids_from_search` is passed as a string value. + +Additionally, optional toolchain & delivery details can be provided on the command line, e.g.:: planet orders create --name "my order" \ --id 20151119_025740_0c74,20151119_025741_0c74 \ --bundle visual --item-type psscene3band --zip order --email This places the same order as above, and will also provide a .zip archive -download link for the full order, as well as email notification. +download link for the full order, as well as email notification. If you change +`--zip order` to `--zip bundle`, the individual bundles will be zipped rather +than the full order. + +You can also clip the items in an order by providing a GeoJSON AOI Geometry +with the `--clip` parameter:: + + planet orders create --name "my order" ... \ + --clip '{ + "type": "Polygon", + "coordinates": [ + [ + [ + -163.828125, + -44.59046718130883 + ], + [ + 181.7578125, + -44.59046718130883 + ], + [ + 181.7578125, + 78.42019327591201 + ], + [ + -163.828125, + 78.42019327591201 + ], + [ + -163.828125, + -44.59046718130883 + ] + ] + ] + }' + +Alternatively, you can specify a file that contains your GeoJSON AOI using the +`@` notation, e.g. `--clip @path/to/aoi.json`. + +It should be noted that if the clip AOI you specify does not intersect with the +items in `--id` or `--ids_from_search` you may end up with a zero result order. +If some of the items intersect, you will receive those items. The Orders API allows you to specify a toolchain of operations to be performed on your order prior to download. To read more about tools & toolchains, visit -`the docs `_ . +`the docs `_ . To add tool operations to your order, use the `--tools` option to specify a json-formatted file containing an array (list) of the desired tools an their @@ -271,7 +400,7 @@ order, you would create a `.json` file similar to the following:: "name_template": "C1232_30_30_{tilex:04d}_{tiley:04d}" } } - ] + ] Similarly, you can also specify cloud delivery options on an order create @@ -279,8 +408,8 @@ command with the `--cloudconfig ` option. In this case, the json file should contain the required credentials for your desired cloud storage destination, for example:: - { - "amazon_s3":{ + { + "amazon_s3":{ "bucket":"foo-bucket", "aws_region":"us-east-2", "aws_access_key_id":"", diff --git a/planet/scripts/types.py b/planet/scripts/types.py index a06d414cc..072341003 100644 --- a/planet/scripts/types.py +++ b/planet/scripts/types.py @@ -309,3 +309,28 @@ def convert(self, val, param, ctx): for date in dates: if date != '..' and strp_lenient(date) is None: raise click.BadParameter('Invalid date: {}'.format(date)) + + +class ClipAOI(click.ParamType): + name = 'clip' + + def convert(self, val, param, ctx): + val = read(val) + if not val: + return [] + try: + json.loads(val) + except ValueError: + raise click.BadParameter('invalid GeoJSON') + return val + + +class RequiredUnless(click.Option): + def __init__(self, *args, **kwargs): + self.this_opt_exists = kwargs.pop('this_opt_exists') + super(RequiredUnless, self).__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + if self.name not in opts and self.this_opt_exists not in opts: + raise click.UsageError('{} is required.'.format(self.name)) + return super(RequiredUnless, self).handle_parse_result(ctx, opts, args) diff --git a/planet/scripts/util.py b/planet/scripts/util.py index da802808c..5fb109459 100644 --- a/planet/scripts/util.py +++ b/planet/scripts/util.py @@ -100,6 +100,7 @@ def create_order_request(**kwargs): email = kwargs.get('email') archive = kwargs.get('zip') config = kwargs.get('cloudconfig') + clip = kwargs.get('clip') tools = kwargs.get('tools') request = {'name': kwargs.get('name'), @@ -108,20 +109,21 @@ def create_order_request(**kwargs): 'product_bundle': bundle} ], 'tools': [ - ], - 'delivery': { - }, - 'notifications': { + ], + 'delivery': { + }, + 'notifications': { 'email': email - }, - } + }, + } if archive is not None: request["delivery"]["archive_filename"] = "{{name}}_{{order_id}}.zip" request["delivery"]["archive_type"] = "zip" - # TODO verify this is correct req format for order vs bundle zip - if archive == "bundle": + # If single_archive is not set, each bundle will be zipped, as opposed + # to the entire order. + if archive == "order": request["delivery"]["single_archive"] = True if config: @@ -129,9 +131,13 @@ def create_order_request(**kwargs): conf = json.load(f) request["delivery"].update(conf) - # TODO determine reasonable interfaces for SOME tools via CLI; - # e.g., clip via provided geojson AOI - # for now we can punt by pointing users to doc examples for copy-pasting + # NOTE clip is the only tool that currently can be specified via CLI param. + # A full tool chain can be specified via JSON file, so that will overwrite + # clip if both are present. TODO add other common tools as params. + if clip and not tools: + toolchain = [{'clip': {'aoi': json.loads(clip)}}] + request['tools'].extend(toolchain) + if tools: with open(tools, 'r') as f: toolchain = json.load(f) @@ -346,3 +352,11 @@ def downloader_output(dl, disable_ansi=False): if termui.WIN and not disable_ansi: logging.getLogger('').setLevel(logging.INFO) return Output(thread, dl) + + +def ids_from_search_response(resp): + ret = [] + r = json.loads(resp) + for feature in r['features']: + ret.append(feature['id']) + return ','.join(ret) diff --git a/planet/scripts/v1.py b/planet/scripts/v1.py index 1670a8d08..28f073eaf 100644 --- a/planet/scripts/v1.py +++ b/planet/scripts/v1.py @@ -13,6 +13,8 @@ # limitations under the License. import click +from click.testing import CliRunner + from itertools import chain import json from .cli import ( @@ -34,7 +36,9 @@ BoundingBox, metavar_docs, DateInterval, - ItemType + ItemType, + RequiredUnless, + ClipAOI, ) from .util import ( call_and_wrap, @@ -45,7 +49,8 @@ echo_json_response, read, search_req_from_opts, - create_order_request + create_order_request, + ids_from_search_response, ) from planet.api.utils import ( handle_interrupt @@ -162,16 +167,16 @@ def _disable_item_type(ctx, param, value): @click.option('--search-id', is_eager=True, callback=_disable_item_type, type=str, help='Use the specified search') @click.option('--dry-run', is_flag=True, help=( - 'Only report the number of items that would be downloaded.' + 'Only report the number of items that would be downloaded.' )) @click.option('--activate-only', is_flag=True, help=( - 'Only activate the items. Outputs URLS for downloading.' + 'Only activate the items. Outputs URLS for downloading.' )) @click.option('--quiet', is_flag=True, help=( - 'Disable ANSI control output' + 'Disable ANSI control output' )) @click.option('--dest', default='.', help=( - 'Location to download files to'), type=click.Path( + 'Location to download files to'), type=click.Path( exists=True, resolve_path=True, writable=True, file_okay=False)) @limit_option(None) @data.command('download', epilog=filter_opts_epilog) @@ -271,8 +276,8 @@ def list_mosaics(pretty, prefix): @mosaics.command('search') @click.argument('name') @click.option('--bbox', type=BoundingBox(), help=( - 'Region to query as a comma-delimited string:' - ' lon_min,lat_min,lon_max,lat_max' + 'Region to query as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' )) @click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') @limit_option(None) @@ -322,15 +327,15 @@ def quad_contributions(name, quad, pretty): @mosaics.command('download') @click.argument('name') @click.option('--bbox', type=BoundingBox(), help=( - 'Region to download as a comma-delimited string:' - ' lon_min,lat_min,lon_max,lat_max' + 'Region to download as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' )) @click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') @click.option('--quiet', is_flag=True, help=( - 'Disable ANSI control output' + 'Disable ANSI control output' )) @click.option('--dest', default='.', help=( - 'Location to download files to'), type=click.Path( + 'Location to download files to'), type=click.Path( exists=True, resolve_path=True, writable=True, file_okay=False )) @limit_option(None) @@ -574,17 +579,17 @@ def features(): @features.command('list') @click.argument('subscription_id') @click.option('--bbox', type=BoundingBox(), help=( - 'Region to query as a comma-delimited string:' - ' lon_min,lat_min,lon_max,lat_max' + 'Region to query as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' )) @click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') @click.option('--time-range', type=DateInterval(), help=( - 'Time interval. Can be open or closed interval, start times are ' - 'inclusive and end times are exclusive: ' - '2019-01-01T00:00:00.00Z/2019-02-01T00:00:00.00Z (Closed interval for ' - 'January 2019), 2019-01-01T00:00:00.00Z/.. (Open interval for all ' - 'items since the start of January 2019), 2019-01-01T00:00:00.00Z ' - '(instant)' + 'Time interval. Can be open or closed interval, start times are ' + 'inclusive and end times are exclusive: ' + '2019-01-01T00:00:00.00Z/2019-02-01T00:00:00.00Z (Closed interval for ' + 'January 2019), 2019-01-01T00:00:00.00Z/.. (Open interval for all ' + 'items since the start of January 2019), 2019-01-01T00:00:00.00Z ' + '(instant)' )) @click.option('--before', type=str, help=( 'Get results published before the item with the provided ID.' @@ -607,17 +612,17 @@ def list_features(subscription_id, pretty, limit, rbox, bbox, time_range, @features.command('list-all') @click.argument('subscription_id') @click.option('--bbox', type=BoundingBox(), help=( - 'Region to query as a comma-delimited string:' - ' lon_min,lat_min,lon_max,lat_max' + 'Region to query as a comma-delimited string:' + ' lon_min,lat_min,lon_max,lat_max' )) @click.option('--rbox', type=BoundingBox(), help='Alias for --bbox') @click.option('--time-range', type=DateInterval(), help=( - 'Time interval. Can be open or closed interval, start times are ' - 'inclusive and end times are exclusive: ' - '2019-01-01T00:00:00.00Z/2019-02-01T00:00:00.00Z (Closed interval for ' - 'January 2019), 2019-01-01T00:00:00.00Z/.. (Open interval for all ' - 'items since the start of January 2019), 2019-01-01T00:00:00.00Z ' - '(instant)' + 'Time interval. Can be open or closed interval, start times are ' + 'inclusive and end times are exclusive: ' + '2019-01-01T00:00:00.00Z/2019-02-01T00:00:00.00Z (Closed interval for ' + 'January 2019), 2019-01-01T00:00:00.00Z/.. (Open interval for all ' + 'items since the start of January 2019), 2019-01-01T00:00:00.00Z ' + '(instant)' )) @click.option('--before', type=str, help=( 'Get results published before the item with the provided ID.' @@ -642,7 +647,7 @@ def list_features_all(subscription_id, pretty, rbox, bbox, time_range, before, @click.argument('subscription_id') @click.argument('feature_id') @click.option('--dest', default='.', help=( - 'Location to download files to'), type=click.Path( + 'Location to download files to'), type=click.Path( exists=True, resolve_path=True, writable=True, file_okay=False )) @pretty @@ -711,8 +716,14 @@ def cancel_order(order_id, pretty): @click.option('--name', required=True) -@click.option('--id', required=True, - help='One or more comma-separated item IDs') +@click.option('--id', help='One or more comma-separated item IDs', + cls=RequiredUnless, this_opt_exists='ids_from_search') +# Note: This is passed as a string, because --item-type is a required field for +# both 'data search' and 'orders create'. +@click.option('--ids_from_search', + help='Embedded data search') +@click.option('--clip', type=ClipAOI(), + help='Provide a GeoJSON AOI Geometry for clipping') @click.option('--email', default=False, is_flag=True, help='Send email notification when Order is complete') @click.option('--zip', type=click.Choice(['order', 'bundle']), @@ -735,6 +746,16 @@ def cancel_order(order_id, pretty): @pretty def create_order(pretty, **kwargs): '''Create an order''' + ids_from_search = kwargs.get('ids_from_search') + if ids_from_search is not None: + runner = CliRunner() + resp = runner.invoke(quick_search, ids_from_search).output + try: + id_list = ids_from_search_response(resp) + except ValueError: + raise click.ClickException('ids_from_search, {}'.format(resp)) + kwargs['id'] = id_list + del kwargs['ids_from_search'] cl = clientv1() request = create_order_request(**kwargs) echo_json_response(call_and_wrap(cl.create_order, request), pretty) @@ -743,12 +764,12 @@ def create_order(pretty, **kwargs): @orders.command('download') @click.argument('order_id', type=click.UUID) @click.option('--quiet', is_flag=True, help=( - 'Disable ANSI control output' + 'Disable ANSI control output' )) @click.option('--dest', default='.', help=( 'Location to download files to'), type=click.Path( exists=True, resolve_path=True, writable=True, file_okay=False - )) +)) @pretty def download_order(order_id, dest, quiet, pretty): '''Download an order by given order ID'''