diff --git a/README.md b/README.md index e10843a03..04b3f6aba 100644 --- a/README.md +++ b/README.md @@ -60,16 +60,16 @@ The email address and password you use should be the same as your login to [Planet Explorer](https://planet.com/explorer). The `auth init` command will automatically get your API key and store it locally. -Now that you're initialized let's start with creating an order with the +Now that you're initialized let's start with creating an order request with the Orders API: ```console -$ planet orders create --name my-first-order --id \ - --item-type PSScene --bundle visual +$ planet orders request --name my-first-order --id \ + --item-type PSScene --bundle visual > my_order.json ``` You should supply a unique name after `--name` for each new order, to help -you identify what oder. The `--id` is one or more scene ids (separated by +you identify the order. The `--id` is one or more scene ids (separated by commas). These can be obtained from the data API, and you can also grab them from any search in Planet Explorer. Just be sure the scene id matches the [item-type](https://developers.planet.com/docs/apis/data/items-assets/#item-types) @@ -77,8 +77,16 @@ to get the right type of image. And then be sure to specify a [bundle](https://developers.planet.com/docs/orders/product-bundles-reference/). The most common ones are `visual` and `analytic`. +Next, you may create an order with the Orders API: +```console +$ planet orders create my_order.json +``` This will give you an order response JSON as shown in the 'example response' in -[the Order API docs](https://developers.planet.com/docs/orders/ordering/#basic-ordering). +[the Order API docs](https://developers.planet.com/docs/orders/ordering/#basic-ordering). You may also pipe the `request` command to the `create` command to avoid the creation of a request.json file: +```console +$ planet orders request -name my-first-order --id \ + --item-type PSScene --bundle visual | planet orders create - +``` You can grab the `id` from that response, which will look something like `dfdf3088-73a2-478c-a8f6-1bad1c09fa09`. You can then use that order-id in a single command to wait for the order and download it when you are ready: diff --git a/planet/cli/orders.py b/planet/cli/orders.py index 9861e2857..74ac4f1a4 100644 --- a/planet/cli/orders.py +++ b/planet/cli/orders.py @@ -247,36 +247,53 @@ def read_file_json(ctx, param, value): @click.pass_context @translate_exceptions @coro -@click.option('--name', required=True) -@click.option('--id', - 'ids', - help='One or more comma-separated item IDs', - type=click.STRING, - callback=split_list_arg, - required=True) -# @click.option('--ids_from_search', -# help='Embedded data search') +@click.argument("request", default="-", required=False) +@pretty +async def create(ctx, request: str, pretty): + ''' Create an order. + + This command creates an order from an order request. + It outputs the created order description, optionally pretty-printed. + + Arguments: + + Order request as stdin, str, or file name. Full description of order + to be created. + ''' + request_json = json.load(click.open_file(request)) + + async with orders_client(ctx) as cl: + order = await cl.create_order(request_json) + + echo_json(order, pretty) + + +@orders.command() +@click.pass_context +@translate_exceptions +@coro +@click.option('--name', + required=True, + help='Order name. Does not need to be unique.', + type=click.STRING) @click.option( '--bundle', multiple=False, required=True, - help='Specify bundle', + help='Product bundle.', type=click.Choice(planet.specs.get_product_bundles(), case_sensitive=False), ) +@click.option('--id', + help='One or more comma-separated item IDs', + type=click.STRING, + callback=split_list_arg, + required=True) @click.option('--item-type', multiple=False, required=True, help='Specify an item type', type=click.STRING) -@click.option('--email', - default=False, - is_flag=True, - help='Send email notification when Order is complete') -@click.option('--cloudconfig', - help='Cloud delivery config json file.', - type=click.File('rb'), - callback=read_file_json) @click.option('--clip', help='Clip GeoJSON file.', type=click.File('rb'), @@ -285,20 +302,35 @@ def read_file_json(ctx, param, value): help='Toolchain json file.', type=click.File('rb'), callback=read_file_json) +@click.option('--email', + default=False, + is_flag=True, + help='Send email notification when Order is complete') +@click.option( + '--cloudconfig', + help='Credentials for cloud storage provider to enable cloud delivery' + 'of data.', + type=click.File('rb'), + callback=read_file_json) @pretty -async def create(ctx, - name, - ids, - bundle, - item_type, - email, - cloudconfig, - clip, - tools, - pretty): - '''Create an order.''' +async def request(ctx, + name, + bundle, + id, + clip, + tools, + item_type, + email, + cloudconfig, + pretty): + """Generate an order request. + + This command provides support for building an order description used + in creating an order. It outputs the order request, optionally pretty- + printed. + """ try: - product = planet.order_request.product(ids, bundle, item_type) + product = planet.order_request.product(id, bundle, item_type) except planet.specs.SpecificationException as e: raise click.BadParameter(e) @@ -307,11 +339,6 @@ async def create(ctx, else: notifications = None - if cloudconfig: - delivery = planet.order_request.delivery(cloud_config=cloudconfig) - else: - delivery = None - if clip and tools: raise click.BadParameter("Specify only one of '--clip' or '--tools'") elif clip: @@ -322,13 +349,15 @@ async def create(ctx, tools = [planet.order_request.clip_tool(clip)] + if cloudconfig: + delivery = planet.order_request.delivery(cloud_config=cloudconfig) + else: + delivery = None + request = planet.order_request.build_request(name, products=[product], delivery=delivery, notifications=notifications, tools=tools) - async with orders_client(ctx) as cl: - order = await cl.create_order(request) - - echo_json(order, pretty) + echo_json(request, pretty) diff --git a/tests/integration/test_orders_cli.py b/tests/integration/test_orders_cli.py index 3688ab09a..18d8820bd 100644 --- a/tests/integration/test_orders_cli.py +++ b/tests/integration/test_orders_cli.py @@ -29,7 +29,7 @@ TEST_URL = 'http://MockNotRealURL/api/path' TEST_DOWNLOAD_URL = f'{TEST_URL}/download' -TEST_ORDERS_URL = f'{TEST_URL}/orders/v2' +TEST_ORDERS_URL = 'https://api.planet.com/compute/ops/orders/v2' # NOTE: These tests use a lot of the same mocked responses as test_orders_api. @@ -39,7 +39,7 @@ def invoke(): def _invoke(extra_args, runner=None): runner = runner or CliRunner() - args = ['orders', '--base-url', TEST_URL] + extra_args + args = ['orders'] + extra_args return runner.invoke(cli.main, args=args) return _invoke @@ -423,30 +423,22 @@ def test_cli_orders_download_state(invoke, order_description, oid): assert 'order state (running) is not a final state.' in result.output -# TODO: convert "create" tests to "request" tests (gh-366). -# TODO: add tests of "create --pretty" (gh-491). +# # TODO: add tests of "create --like" (gh-557). +# # TODO: add tests of "create --pretty" (gh-491). @pytest.mark.parametrize( "id_string, expected_ids", [('4500474_2133707_2021-05-20_2419', ['4500474_2133707_2021-05-20_2419']), ('4500474_2133707_2021-05-20_2419,4500474_2133707_2021-05-20_2420', ['4500474_2133707_2021-05-20_2419', '4500474_2133707_2021-05-20_2420'])]) -@respx.mock -def test_cli_orders_create_basic_success(expected_ids, - id_string, - invoke, - order_description): - mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) - respx.post(TEST_ORDERS_URL).return_value = mock_resp - +def test_cli_orders_request_basic_success(expected_ids, id_string, invoke): result = invoke([ - 'create', + 'request', '--name=test', f'--id={id_string}', '--bundle=analytic', '--item-type=PSOrthoTile' ]) assert not result.exception - assert order_description == json.loads(result.output) order_request = { "name": @@ -457,13 +449,12 @@ def test_cli_orders_create_basic_success(expected_ids, "product_bundle": "analytic" }], } - sent_request = json.loads(respx.calls.last.request.content) - assert sent_request == order_request + assert order_request == json.loads(result.output) -def test_cli_orders_create_basic_item_type_invalid(invoke): +def test_cli_orders_request_basic_item_type_invalid(invoke): result = invoke([ - 'create', + 'request', '--name=test', '--id=4500474_2133707_2021-05-20_2419', '--bundle=analytic', @@ -473,9 +464,9 @@ def test_cli_orders_create_basic_item_type_invalid(invoke): assert 'Error: Invalid value: item_type' in result.output -def test_cli_orders_create_id_empty(invoke): +def test_cli_orders_request_id_empty(invoke): result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -489,18 +480,114 @@ def test_cli_orders_create_id_empty(invoke): assert 'Entry cannot be an empty string.' in result.output +def test_cli_orders_request_clip(invoke, geom_geojson, write_to_tmp_json_file): + aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson') + + result = invoke([ + 'request', + '--name', + 'test', + '--id', + '4500474_2133707_2021-05-20_2419', + '--bundle', + 'analytic', + '--item-type', + 'PSOrthoTile', + '--clip', + aoi_file + ]) + assert not result.exception + + order_request = { + "name": + "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "tools": [{ + 'clip': { + 'aoi': geom_geojson + } + }] + } + assert order_request == json.loads(result.output) + + +# TODO: add tests of "create --pretty" (gh-491). +@pytest.mark.parametrize( + "id_string, expected_ids", + [('4500474_2133707_2021-05-20_2419', ['4500474_2133707_2021-05-20_2419']), + ('4500474_2133707_2021-05-20_2419,4500474_2133707_2021-05-20_2420', + ['4500474_2133707_2021-05-20_2419', '4500474_2133707_2021-05-20_2420'])]) +@respx.mock +def test_cli_orders_create_basic_success(expected_ids, + id_string, + invoke, + order_description, + write_to_tmp_json_file): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + request_result = invoke([ + 'request', + '--name=test', + f'--id={id_string}', + '--bundle=analytic', + '--item-type=PSOrthoTile' + ]) + request_file = write_to_tmp_json_file(json.loads(request_result.output), + 'orders.json') + + result = CliRunner().invoke(cli.main, + ['orders', 'create', str(request_file)], + catch_exceptions=True) + + assert result.exit_code == 0 + assert json.loads( + result.output)['_links']['results'][0]['delivery'] == 'success' + + +@pytest.mark.parametrize( + "id_string, expected_ids", + [('4500474_2133707_2021-05-20_2419', ['4500474_2133707_2021-05-20_2419']), + ('4500474_2133707_2021-05-20_2419,4500474_2133707_2021-05-20_2420', + ['4500474_2133707_2021-05-20_2419', '4500474_2133707_2021-05-20_2420'])]) @respx.mock -def test_cli_orders_create_clip(invoke, - geom_geojson, - order_description, - write_to_tmp_json_file): +def test_cli_orders_create_basic_stdin_success(expected_ids, + id_string, + invoke, + order_description): mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.post(TEST_ORDERS_URL).return_value = mock_resp - aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson') + request_result = invoke([ + 'request', + '--name=test', + f'--id={id_string}', + '--bundle=analytic', + '--item-type=PSOrthoTile' + ]) + + result = CliRunner().invoke(cli.main, ['orders', 'create', '-'], + input=request_result.output, + catch_exceptions=True) + + assert result.exit_code == 0 + assert json.loads( + result.output)['_links']['results'][0]['delivery'] == 'success' + + +def test_cli_orders_request_clip_featureclass(invoke, + feature_geojson, + geom_geojson, + write_to_tmp_json_file): + """Tests that the clip option takes in feature class geojson as well""" + fc_file = write_to_tmp_json_file(feature_geojson, 'fc.geojson') result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -510,7 +597,7 @@ def test_cli_orders_create_clip(invoke, '--item-type', 'PSOrthoTile', '--clip', - aoi_file + fc_file ]) assert not result.exception @@ -528,11 +615,9 @@ def test_cli_orders_create_clip(invoke, } }] } - sent_request = json.loads(respx.calls.last.request.content) - assert sent_request == order_request + assert order_request == json.loads(result.output) -@respx.mock def test_cli_orders_create_clip_featurecollection(invoke, featurecollection_geojson, geom_geojson, @@ -545,7 +630,7 @@ def test_cli_orders_create_clip_featurecollection(invoke, fc_file = write_to_tmp_json_file(featurecollection_geojson, 'fc.geojson') result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -573,17 +658,55 @@ def test_cli_orders_create_clip_featurecollection(invoke, } }] } - sent_request = json.loads(respx.calls.last.request.content) - assert sent_request == order_request + assert order_request == json.loads(result.output) + +@respx.mock +def test_cli_orders_create_clip_featureclass(invoke, + feature_geojson, + geom_geojson, + order_description, + write_to_tmp_json_file): + """Tests that the clip option takes in feature class geojson as well""" + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp -def test_cli_orders_create_clip_invalid_geometry(invoke, - point_geom_geojson, - write_to_tmp_json_file): + fc_file = write_to_tmp_json_file(feature_geojson, 'fc.geojson') + + request_result = invoke([ + 'request', + '--name', + 'test', + '--id', + '4500474_2133707_2021-05-20_2419', + '--bundle', + 'analytic', + '--item-type', + 'PSOrthoTile', + '--clip', + fc_file + ]) + + request_file = write_to_tmp_json_file(json.loads(request_result.output), + 'orders.json') + + # Invoke the create call + result = CliRunner().invoke(cli.main, + ['orders', 'create', str(request_file)], + catch_exceptions=True) + + assert result.exit_code == 0 + assert json.loads( + result.output)['_links']['results'][0]['delivery'] == 'success' + + +def test_cli_orders_request_clip_invalid_geometry(invoke, + point_geom_geojson, + write_to_tmp_json_file): aoi_file = write_to_tmp_json_file(point_geom_geojson, 'aoi.geojson') result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -601,15 +724,15 @@ def test_cli_orders_create_clip_invalid_geometry(invoke, assert error_msg in result.output -def test_cli_orders_create_clip_and_tools(invoke, - geom_geojson, - write_to_tmp_json_file): +def test_cli_orders_request_clip_and_tools(invoke, + geom_geojson, + write_to_tmp_json_file): # interestingly, it is important that both clip and tools # option values lead to valid json files aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson') result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -627,14 +750,7 @@ def test_cli_orders_create_clip_and_tools(invoke, assert "Specify only one of '--clip' or '--tools'" in result.output -@respx.mock -def test_cli_orders_create_cloudconfig(invoke, - geom_geojson, - order_description, - write_to_tmp_json_file): - mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) - respx.post(TEST_ORDERS_URL).return_value = mock_resp - +def test_cli_orders_request_cloudconfig(invoke, write_to_tmp_json_file): config_json = { 'amazon_s3': { 'aws_access_key_id': 'aws_access_key_id', @@ -647,7 +763,7 @@ def test_cli_orders_create_cloudconfig(invoke, config_file = write_to_tmp_json_file(config_json, 'config.json') result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -672,17 +788,58 @@ def test_cli_orders_create_cloudconfig(invoke, "delivery": config_json } - sent_request = json.loads(respx.calls.last.request.content) - assert sent_request == order_request + assert order_request == json.loads(result.output) @respx.mock -def test_cli_orders_create_email(invoke, geom_geojson, order_description): +def test_cli_orders_create_cloudconfig(invoke, + order_description, + write_to_tmp_json_file): mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.post(TEST_ORDERS_URL).return_value = mock_resp + config_json = { + 'amazon_s3': { + 'aws_access_key_id': 'aws_access_key_id', + 'aws_secret_access_key': 'aws_secret_access_key', + 'bucket': 'bucket', + 'aws_region': 'aws_region' + }, + 'archive_type': 'zip' + } + config_file = write_to_tmp_json_file(config_json, 'config.json') + + request_result = invoke([ + 'request', + '--name', + 'test', + '--id', + '4500474_2133707_2021-05-20_2419', + '--bundle', + 'analytic', + '--item-type', + 'PSOrthoTile', + '--cloudconfig', + config_file + ]) + + request_file = write_to_tmp_json_file(json.loads(request_result.output), + 'orders.json') + + # Invoke the create call + # Invoke the create call + result = CliRunner().invoke(cli.main, + ['orders', 'create', str(request_file)], + catch_exceptions=True) + + assert result.exit_code == 0 + assert json.loads( + result.output)['_links']['results'][0]['delivery'] == 'success' + + +def test_cli_orders_request_email(invoke): result = invoke([ - 'create', + 'request', '--name', 'test', '--id', @@ -707,23 +864,50 @@ def test_cli_orders_create_email(invoke, geom_geojson, order_description): "email": True } } - sent_request = json.loads(respx.calls.last.request.content) - assert sent_request == order_request + assert order_request == json.loads(result.output) @respx.mock -def test_cli_orders_create_tools(invoke, - geom_geojson, +def test_cli_orders_create_email(invoke, order_description, write_to_tmp_json_file): mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.post(TEST_ORDERS_URL).return_value = mock_resp + request_result = invoke([ + 'request', + '--name', + 'test', + '--id', + '4500474_2133707_2021-05-20_2419', + '--bundle', + 'analytic', + '--item-type', + 'PSOrthoTile', + '--email' + ]) + + request_file = write_to_tmp_json_file(json.loads(request_result.output), + 'orders.json') + + # Invoke the create call + # Invoke the create call + result = CliRunner().invoke(cli.main, + ['orders', 'create', str(request_file)], + catch_exceptions=True) + + assert result.exit_code == 0 + assert json.loads( + result.output)['_links']['results'][0]['delivery'] == 'success' + + +def test_cli_orders_request_tools(invoke, geom_geojson, + write_to_tmp_json_file): tools_json = [{'clip': {'aoi': geom_geojson}}, {'composite': {}}] tools_file = write_to_tmp_json_file(tools_json, 'tools.json') result = invoke([ - 'create', + 'request', '--name=test', '--id=4500474_2133707_2021-05-20_2419', '--bundle=analytic', @@ -743,13 +927,46 @@ def test_cli_orders_create_tools(invoke, "tools": tools_json } - sent_request = json.loads(respx.calls.last.request.content) - assert sent_request == order_request + assert order_request == json.loads(result.output) + + +@respx.mock +def test_cli_orders_create_tools(invoke, + geom_geojson, + order_description, + write_to_tmp_json_file): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + tools_json = [{'clip': {'aoi': geom_geojson}}, {'composite': {}}] + tools_file = write_to_tmp_json_file(tools_json, 'tools.json') + + request_result = invoke([ + 'request', + '--name=test', + '--id=4500474_2133707_2021-05-20_2419', + '--bundle=analytic', + '--item-type=PSOrthoTile', + f'--tools={tools_file}' + ]) + + request_file = write_to_tmp_json_file(json.loads(request_result.output), + 'orders.json') + + # Invoke the create call + # Invoke the create call + result = CliRunner().invoke(cli.main, + ['orders', 'create', str(request_file)], + catch_exceptions=True) + + assert result.exit_code == 0 + assert json.loads( + result.output)['_links']['results'][0]['delivery'] == 'success' def test_cli_orders_read_file_json_doesnotexist(invoke): result = invoke([ - 'create', + 'request', '--name=test', '--id=4500474_2133707_2021-05-20_2419', '--bundle=analytic', @@ -768,7 +985,7 @@ def test_cli_orders_read_file_json_invalidjson(invoke, tmp_path): fp.write('[Invali]d j*son') result = invoke([ - 'create', + 'request', '--name=test', '--id=4500474_2133707_2021-05-20_2419', '--bundle=analytic',