diff --git a/planet/cli/data.py b/planet/cli/data.py index 0c2958a3..8000927f 100644 --- a/planet/cli/data.py +++ b/planet/cli/data.py @@ -19,7 +19,7 @@ import click from planet.reporting import AssetStatusBar -from planet import data_filter, DataClient, exceptions +from planet import data_filter, DataClient, exceptions, geojson from planet.clients.data import (SEARCH_SORT, LIST_SEARCH_TYPE, LIST_SEARCH_TYPE_DEFAULT, @@ -81,6 +81,11 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]: raise click.BadParameter(str(e)) +def check_geom(ctx, param, geometry: Optional[dict]) -> Optional[dict]: + """Validates geometry as GeoJSON or feature ref(s).""" + return geojson.as_geom_or_ref(geometry) if geometry else None + + def check_item_type(ctx, param, item_type) -> Optional[List[dict]]: """Validates the item type provided by comparing it to all supported item types.""" @@ -281,6 +286,7 @@ def filter(ctx, @click.argument("item_types", type=types.CommaSeparatedString(), callback=check_item_types) +@click.option("--geom", type=types.JSON(), callback=check_geom) @click.option('--filter', type=types.JSON(), help="""Apply specified filter to search. Can be a json string, @@ -293,7 +299,7 @@ def filter(ctx, show_default=True, help='Field and direction to order results by.') @pretty -async def search(ctx, item_types, filter, limit, name, sort, pretty): +async def search(ctx, item_types, geom, filter, limit, name, sort, pretty): """Execute a structured item search. This function outputs a series of GeoJSON descriptions, one for each of the @@ -311,6 +317,7 @@ async def search(ctx, item_types, filter, limit, name, sort, pretty): async with data_client(ctx) as cl: async for item in cl.search(item_types, + geometry=geom, search_filter=filter, name=name, sort=sort, @@ -325,6 +332,7 @@ async def search(ctx, item_types, filter, limit, name, sort, pretty): @click.argument("item_types", type=types.CommaSeparatedString(), callback=check_item_types) +@click.option("--geom", type=types.JSON(), callback=check_geom) @click.option( '--filter', type=types.JSON(), @@ -339,7 +347,13 @@ async def search(ctx, item_types, filter, limit, name, sort, pretty): is_flag=True, help='Send a daily email when new results are added.') @pretty -async def search_create(ctx, item_types, filter, name, daily_email, pretty): +async def search_create(ctx, + item_types, + geom, + filter, + name, + daily_email, + pretty): """Create a new saved structured item search. This function outputs a full JSON description of the created search, @@ -349,6 +363,7 @@ async def search_create(ctx, item_types, filter, name, daily_email, pretty): """ async with data_client(ctx) as cl: items = await cl.create_search(item_types=item_types, + geometry=geom, search_filter=filter, name=name, enable_email=daily_email) @@ -485,6 +500,7 @@ async def search_delete(ctx, search_id): type=str, required=True, help='Name of the saved search.') +@click.option("--geom", type=types.JSON(), callback=check_geom, default=None) @click.option('--daily-email', is_flag=True, help='Send a daily email when new results are added.') @@ -493,6 +509,7 @@ async def search_update(ctx, search_id, item_types, filter, + geom, name, daily_email, pretty): @@ -504,9 +521,10 @@ async def search_update(ctx, async with data_client(ctx) as cl: items = await cl.update_search(search_id, item_types, - filter, - name, - daily_email) + search_filter=filter, + name=name, + geometry=geom, + enable_email=daily_email) echo_json(items, pretty) diff --git a/planet/clients/data.py b/planet/clients/data.py index 7b8133a8..63898874 100644 --- a/planet/clients/data.py +++ b/planet/clients/data.py @@ -26,6 +26,7 @@ from ..http import Session from ..models import Paged, StreamingBody from ..specs import validate_data_item_type +from ..geojson import as_geom_or_ref BASE_URL = f'{PLANET_BASE_URL}/data/v1/' SEARCHES_PATH = '/searches' @@ -112,6 +113,7 @@ def _item_url(self, item_type, item_id): async def search(self, item_types: List[str], + geometry: Optional[dict] = None, search_filter: Optional[dict] = None, name: Optional[str] = None, sort: Optional[str] = None, @@ -134,6 +136,8 @@ async def search(self, sort: Field and direction to order results by. Valid options are given in SEARCH_SORT. name: The name of the saved search. + geometry: GeoJSON, a feature reference or a list of feature + references limit: Maximum number of results to return. When set to 0, no maximum is applied. @@ -149,6 +153,9 @@ async def search(self, item_types = [validate_data_item_type(item) for item in item_types] request_json = {'filter': search_filter, 'item_types': item_types} + + if geometry: + request_json['geometry'] = as_geom_or_ref(geometry) if name: request_json['name'] = name @@ -159,7 +166,6 @@ async def search(self, raise exceptions.ClientError( f'{sort} must be one of {SEARCH_SORT}') params['_sort'] = sort - response = await self._session.request(method='POST', url=url, json=request_json, @@ -171,6 +177,7 @@ async def create_search(self, item_types: List[str], search_filter: dict, name: str, + geometry: Optional[dict] = None, enable_email: bool = False) -> dict: """Create a new saved structured item search. @@ -192,6 +199,7 @@ async def create_search(self, Parameters: item_types: The item types to include in the search. + geometry: A feature reference or a GeoJSON search_filter: Structured search criteria. name: The name of the saved search. enable_email: Send a daily email when new results are added. @@ -205,12 +213,15 @@ async def create_search(self, url = self._searches_url() item_types = [validate_data_item_type(item) for item in item_types] + request = { 'name': name, 'filter': search_filter, 'item_types': item_types, '__daily_email_enabled': enable_email } + if geometry: + request['geometry'] = as_geom_or_ref(geometry) response = await self._session.request(method='POST', url=url, @@ -222,12 +233,14 @@ async def update_search(self, item_types: List[str], search_filter: dict, name: str, + geometry: Optional[dict] = None, enable_email: bool = False) -> dict: """Update an existing saved search. Parameters: search_id: Saved search identifier. item_types: The item types to include in the search. + geometry: A feature reference or a GeoJSON search_filter: Structured search criteria. name: The name of the saved search. enable_email: Send a daily email when new results are added. @@ -238,12 +251,15 @@ async def update_search(self, url = f'{self._searches_url()}/{search_id}' item_types = [validate_data_item_type(item) for item in item_types] + request = { 'name': name, 'filter': search_filter, 'item_types': item_types, '__daily_email_enabled': enable_email } + if geometry: + request['geometry'] = geometry response = await self._session.request(method='PUT', url=url, diff --git a/tests/integration/test_data_api.py b/tests/integration/test_data_api.py index 7a57f539..a98e0dc3 100644 --- a/tests/integration/test_data_api.py +++ b/tests/integration/test_data_api.py @@ -140,6 +140,52 @@ async def test_search_name(item_descriptions, search_response, session): assert items_list == item_descriptions +@respx.mock +@pytest.mark.anyio +@pytest.mark.parametrize("geom_fixture", [('geom_geojson'), + ('geom_reference')]) +async def test_search_geometry(geom_fixture, + item_descriptions, + session, + request): + + quick_search_url = f'{TEST_URL}/quick-search' + next_page_url = f'{TEST_URL}/blob/?page_marker=IAmATest' + + item1, item2, item3 = item_descriptions + page1_response = { + "_links": { + "_next": next_page_url + }, "features": [item1, item2] + } + mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response) + respx.post(quick_search_url).return_value = mock_resp1 + + page2_response = {"_links": {"_self": next_page_url}, "features": [item3]} + mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response) + respx.get(next_page_url).return_value = mock_resp2 + + cl = DataClient(session, base_url=TEST_URL) + geom = request.getfixturevalue(geom_fixture) + items_list = [ + i async for i in cl.search( + ['PSScene'], name='quick_search', geometry=geom) + ] + # check that request is correct + expected_request = { + "item_types": ["PSScene"], + "geometry": geom, + "filter": data_filter.empty_filter(), + "name": "quick_search" + } + actual_body = json.loads(respx.calls[0].request.content) + + assert actual_body == expected_request + + # check that all of the items were returned unchanged + assert items_list == item_descriptions + + @respx.mock @pytest.mark.anyio async def test_search_filter(item_descriptions, @@ -197,7 +243,10 @@ async def test_search_sort(item_descriptions, cl = DataClient(session, base_url=TEST_URL) # run through the iterator to actually initiate the call - [i async for i in cl.search(['PSScene'], search_filter, sort=sort)] + [ + i async for i in cl.search( + ['PSScene'], search_filter=search_filter, sort=sort) + ] @respx.mock @@ -218,7 +267,8 @@ async def test_search_limit(item_descriptions, cl = DataClient(session, base_url=TEST_URL) items_list = [ - i async for i in cl.search(['PSScene'], search_filter, limit=2) + i async for i in cl.search( + ['PSScene'], search_filter=search_filter, limit=2) ] # check only the first two results were returned diff --git a/tests/integration/test_data_cli.py b/tests/integration/test_data_cli.py index ddb04b28..aff36c2f 100644 --- a/tests/integration/test_data_cli.py +++ b/tests/integration/test_data_cli.py @@ -415,9 +415,7 @@ def test_data_filter_update(invoke, assert_and_filters_equal): @respx.mock -@pytest.mark.parametrize("item_types, expect_success", - [('PSScene', True), ('SkySatScene', True), - ('PSScene, SkySatScene', True), ('INVALID', False)]) +@pytest.mark.parametrize("item_types, expect_success", [('PSScene', True)]) def test_data_search_cmd_item_types(item_types, expect_success, invoke): """Test for planet data search_quick item types, valid and invalid.""" mock_resp = httpx.Response(HTTPStatus.OK, @@ -435,6 +433,24 @@ def test_data_search_cmd_item_types(item_types, expect_success, invoke): assert result.exit_code == 2 +@respx.mock +@pytest.mark.parametrize("geom_fixture", + [('geom_geojson'), ('feature_geojson'), + ('featurecollection_geojson'), ('geom_reference')]) +def test_data_search_cmd_top_level_geom(geom_fixture, request, invoke): + """Ensure that all GeoJSON forms of describing a geometry are handled + and all result in the same, valid GeometryFilter being created""" + mock_resp = httpx.Response(HTTPStatus.OK, + json={'features': [{ + "key": "value" + }]}) + respx.post(TEST_QUICKSEARCH_URL).return_value = mock_resp + geom = request.getfixturevalue(geom_fixture) + + result = invoke(["search", 'PSScene', f"--geom={json.dumps(geom)}"]) + assert result.exit_code == 0 + + @respx.mock @pytest.mark.parametrize("filter", ['{1:1}', '{"foo"}']) def test_data_search_cmd_filter_invalid_json(invoke, filter):