From f94d68cfa9349a176325482646d16fdfeefac8da Mon Sep 17 00:00:00 2001 From: Andy Gaither Date: Thu, 11 Apr 2024 15:34:21 -0600 Subject: [PATCH 1/5] Allow use of feature references --- docs/cli/cli-tips-tricks.md | 12 +++++ planet/data_filter.py | 7 +-- planet/exceptions.py | 4 ++ planet/geojson.py | 56 ++++++++++++++------- planet/order_request.py | 4 +- planet/subscription_request.py | 8 +-- tests/conftest.py | 26 +++++++--- tests/integration/test_subscriptions_cli.py | 18 ++++--- tests/unit/test_geojson.py | 54 +++++++++++--------- 9 files changed, 121 insertions(+), 68 deletions(-) diff --git a/docs/cli/cli-tips-tricks.md b/docs/cli/cli-tips-tricks.md index fc0da6940..81d4bfb83 100644 --- a/docs/cli/cli-tips-tricks.md +++ b/docs/cli/cli-tips-tricks.md @@ -39,6 +39,18 @@ getting the geometry input for searching or clipping. Hand-editing GeoJSON is a people will open up a desktop tool like QGIS or ArcGIS Pro and save the file. But there are a few tools that can get you back into the CLI workflow more quickly. +#### Use the Features API +Rather than using GeoJSON in the SDK, upload your GeoJSON to the [Features API](https://developers.planet.com/docs/apis/features/) and use references +across the system with the sdk. +References are used in the geometry block of our services like: +```json +"geometry": + { + "content": "pl:features/my/[collection-id]/[feature-id]", + "type": "ref" + } +``` + #### Draw with GeoJSON.io One great tool for quickly drawing on a map and getting GeoJSON output is diff --git a/planet/data_filter.py b/planet/data_filter.py index 6bc44862f..ef7d10f0c 100644 --- a/planet/data_filter.py +++ b/planet/data_filter.py @@ -238,9 +238,10 @@ def geometry_filter(geom: dict) -> dict: geom: GeoJSON describing the filter geometry, feature, or feature collection. """ - return _field_filter('GeometryFilter', - field_name='geometry', - config=geojson.as_geom(geom)) + geom_filter = _field_filter('GeometryFilter', + field_name='geometry', + config=geojson.validate_geom_as_geojson(geom)) + return geom_filter def number_in_filter(field_name: str, values: List[float]) -> dict: diff --git a/planet/exceptions.py b/planet/exceptions.py index 5e44486a2..eee852bd0 100644 --- a/planet/exceptions.py +++ b/planet/exceptions.py @@ -90,3 +90,7 @@ class PagingError(ClientError): class GeoJSONError(ClientError): """Errors that occur due to invalid GeoJSON""" + + +class FeatureError(ClientError): + """Errors that occur due to incorrectly formatted feature reference""" diff --git a/planet/geojson.py b/planet/geojson.py index 87fc84296..77bb3247e 100644 --- a/planet/geojson.py +++ b/planet/geojson.py @@ -12,7 +12,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under # the License. -"""Functionality for interacting with GeoJSON.""" +"""Functionality for interacting with GeoJSON and planet references.""" import json import logging import typing @@ -21,14 +21,14 @@ from jsonschema import Draft7Validator from .constants import DATA_DIR -from .exceptions import GeoJSONError +from .exceptions import GeoJSONError, FeatureError -GEOJSON_TYPES = ['Feature'] +GEOJSON_TYPES = ["Feature"] LOGGER = logging.getLogger(__name__) -def as_geom(data: dict) -> dict: +def as_geom_or_ref(data: dict) -> dict: """Extract the geometry from GeoJSON and validate. Parameters: @@ -42,13 +42,30 @@ def as_geom(data: dict) -> dict: or FeatureCollection or if more than one Feature is in a FeatureCollection. """ - geom = geom_from_geojson(data) - validate_geom(geom) - return geom + geom_type = data['type'] + if geom_type == 'ref': + return as_ref(data) + else: + geom = geom_from_geojson(data) + validate_geom_as_geojson(geom) + return geom + + +def as_ref(data: dict) -> dict: + geom_type = data['type'] + if geom_type.lower() != 'ref': + raise FeatureError( + f'Invalid geometry reference: {geom_type} is not a reference (the type should be "ref").' + ) + if "content" not in data: + raise FeatureError( + 'Invalid geometry reference: Missing content block that contains the reference.' + ) + return data def as_polygon(data: dict) -> dict: - geom = as_geom(data) + geom = as_geom_or_ref(data) geom_type = geom['type'] if geom_type.lower() != 'polygon': raise GeoJSONError( @@ -75,7 +92,7 @@ def geom_from_geojson(data: dict) -> dict: else: try: # feature - ret = as_geom(data['geometry']) + ret = as_geom_or_ref(data['geometry']) except KeyError: try: # FeatureCollection @@ -88,11 +105,11 @@ def geom_from_geojson(data: dict) -> dict: 'FeatureCollection has multiple features. Only one feature' ' can be used to get geometry.') - ret = as_geom(features[0]) + ret = as_geom_or_ref(features[0]) return ret -def validate_geom(data: dict): +def validate_geom_as_geojson(data: dict): """Validate GeoJSON geometry. Parameters: @@ -101,23 +118,26 @@ def validate_geom(data: dict): Raises: planet.exceptions.GeoJSONError: If data is not a valid GeoJSON geometry. + Returns: + GeoJSON """ + data = geom_from_geojson(data) if 'type' not in data: - raise GeoJSONError("Missing 'type' key.") + raise GeoJSONError('Missing "type" key.') if 'coordinates' not in data: - raise GeoJSONError("Missing 'coordinates' key.") + raise GeoJSONError('Missing "coordinates" key.') try: - cls = getattr(gj, data["type"]) - obj = cls(data["coordinates"]) + cls = getattr(gj, data['type']) + obj = cls(data['coordinates']) if not obj.is_valid: raise GeoJSONError(obj.errors()) except AttributeError as err: - raise GeoJSONError("Not a GeoJSON geometry type") from err + raise GeoJSONError('Not a GeoJSON geometry type') from err except ValueError as err: - raise GeoJSONError("Not a GeoJSON coordinate value") from err + raise GeoJSONError('Not a GeoJSON coordinate value') from err - return + return data def as_featurecollection(features: typing.List[dict]) -> dict: diff --git a/planet/order_request.py b/planet/order_request.py index 0d4b60301..074d7bdd6 100644 --- a/planet/order_request.py +++ b/planet/order_request.py @@ -356,9 +356,9 @@ def clip_tool(aoi: dict) -> dict: planet.exceptions.ClientError: If GeoJSON is not a valid polygon or multipolygon. """ - valid_types = ['Polygon', 'MultiPolygon'] + valid_types = ['Polygon', 'MultiPolygon', 'ref'] - geom = geojson.as_geom(aoi) + geom = geojson.as_geom_or_ref(aoi) if geom['type'].lower() not in [v.lower() for v in valid_types]: raise ClientError( f'Invalid geometry type: {geom["type"]} is not in {valid_types}.') diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 63bdcd93d..361b3d71e 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -250,7 +250,7 @@ def catalog_source( parameters = { "item_types": item_types, "asset_types": asset_types, - "geometry": geojson.as_geom(dict(geometry)), + "geometry": geojson.as_geom_or_ref(dict(geometry)), } try: @@ -355,7 +355,7 @@ def planetary_variable_source( parameters = { "id": var_id, - "geometry": geojson.as_geom(dict(geometry)), + "geometry": geojson.as_geom_or_ref(dict(geometry)), } try: @@ -596,9 +596,9 @@ def clip_tool(aoi: Mapping) -> dict: planet.exceptions.ClientError: If aoi is not a valid polygon or multipolygon. """ - valid_types = ['Polygon', 'MultiPolygon'] + valid_types = ['Polygon', 'MultiPolygon', 'ref'] - geom = geojson.as_geom(dict(aoi)) + geom = geojson.as_geom_or_ref(dict(aoi)) if geom['type'].lower() not in [v.lower() for v in valid_types]: raise ClientError( f'Invalid geometry type: {geom["type"]} is not in {valid_types}.') diff --git a/tests/conftest.py b/tests/conftest.py index 1013ae625..79f851ba5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,14 +108,24 @@ def geom_geojson(): # these need to be tuples, not list, or they will be changed # by shapely return { - "type": - "Polygon", - "coordinates": - [[[37.791595458984375, 14.84923123791421], - [37.90214538574219, 14.84923123791421], - [37.90214538574219, 14.945448293647944], - [37.791595458984375, 14.945448293647944], - [37.791595458984375, 14.84923123791421]]] + "type": "Polygon", + "coordinates": [ + [ + [37.791595458984375, 14.84923123791421], + [37.90214538574219, 14.84923123791421], + [37.90214538574219, 14.945448293647944], + [37.791595458984375, 14.945448293647944], + [37.791595458984375, 14.84923123791421], + ] + ], + } # yapf: disable + + +@pytest.fixture +def geom_reference(): + return { + "type": "ref", + "content": "pl:features/my/water-fields-RqB0NZ5/rmQEGqm", } # yapf: disable diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py index ded9d3dd1..fd276e6c6 100644 --- a/tests/integration/test_subscriptions_cli.py +++ b/tests/integration/test_subscriptions_cli.py @@ -373,19 +373,21 @@ def test_request_catalog_success(invoke, geom_geojson): @res_api_mock def test_subscriptions_results_csv(invoke): """Get results as CSV.""" - result = invoke(['results', 'test', '--csv']) + result = invoke(["results", "test", "--csv"]) assert result.exit_code == 0 # success. - assert result.output.splitlines() == ['id,status', '1234-abcd,SUCCESS'] + assert result.output.splitlines() == ["id,status", "1234-abcd,SUCCESS"] -def test_request_pv_success(invoke, geom_geojson): +@pytest.mark.parametrize("geom", ["geom_geojson", "geom_reference"]) +def test_request_pv_success(invoke, geom, request): """Request-pv command succeeds""" + geom = request.getfixturevalue(geom) result = invoke([ - 'request-pv', - '--var-type=biomass_proxy', - '--var-id=BIOMASS-PROXY_V3.0_10', - f"--geometry={json.dumps(geom_geojson)}", - '--start-time=2021-03-01T00:00:00' + "request-pv", + "--var-type=biomass_proxy", + "--var-id=BIOMASS-PROXY_V3.0_10", + f"--geometry={json.dumps(geom)}", + "--start-time=2021-03-01T00:00:00", ]) assert result.exit_code == 0 # success. diff --git a/tests/unit/test_geojson.py b/tests/unit/test_geojson.py index c5f3f6f54..9ed1a4dce 100644 --- a/tests/unit/test_geojson.py +++ b/tests/unit/test_geojson.py @@ -40,7 +40,7 @@ def test_geom_from_geojson_success(geom_geojson, feature_geojson, featurecollection_geojson, assert_geom_equal): - ggeo = geojson.as_geom(geom_geojson) + ggeo = geojson.as_geom_or_ref(geom_geojson) assert_geom_equal(ggeo, geom_geojson) fgeo = geojson.geom_from_geojson(feature_geojson) @@ -51,19 +51,19 @@ def test_geom_from_geojson_success(geom_geojson, def test_geom_from_geojson_no_geometry(feature_geojson): - feature_geojson.pop('geometry') + feature_geojson.pop("geometry") with pytest.raises(exceptions.GeoJSONError): _ = geojson.geom_from_geojson(feature_geojson) def test_geom_from_geojson_missing_coordinates(geom_geojson): - geom_geojson.pop('coordinates') + geom_geojson.pop("coordinates") with pytest.raises(exceptions.GeoJSONError): _ = geojson.geom_from_geojson(geom_geojson) def test_geom_from_geojson_missing_type(geom_geojson): - geom_geojson.pop('type') + geom_geojson.pop("type") with pytest.raises(exceptions.GeoJSONError): _ = geojson.geom_from_geojson(geom_geojson) @@ -71,42 +71,46 @@ def test_geom_from_geojson_missing_type(geom_geojson): def test_geom_from_geojson_multiple_features(featurecollection_geojson): # duplicate the feature featurecollection_geojson[ - 'features'] = 2 * featurecollection_geojson['features'] + "features"] = 2 * featurecollection_geojson["features"] with pytest.raises(geojson.GeoJSONError): _ = geojson.geom_from_geojson(featurecollection_geojson) -def test_validate_geom_invalid_type(geom_geojson): - geom_geojson['type'] = 'invalid' +def test_validate_geom_as_geojson_invalid_type(geom_geojson): + geom_geojson["type"] = "invalid" with pytest.raises(exceptions.GeoJSONError): - _ = geojson.validate_geom(geom_geojson) + _ = geojson.validate_geom_as_geojson(geom_geojson) -def test_validate_geom_wrong_type(geom_geojson): - geom_geojson['type'] = 'point' +def test_validate_geom_as_geojson_wrong_type(geom_geojson): + geom_geojson["type"] = "point" with pytest.raises(exceptions.GeoJSONError): - _ = geojson.validate_geom(geom_geojson) + _ = geojson.validate_geom_as_geojson(geom_geojson) -def test_validate_geom_invalid_coordinates(geom_geojson): - geom_geojson['coordinates'] = 'invalid' +def test_validate_geom_as_geojson_invalid_coordinates(geom_geojson): + geom_geojson["coordinates"] = "invalid" with pytest.raises(exceptions.GeoJSONError): - _ = geojson.validate_geom(geom_geojson) + _ = geojson.validate_geom_as_geojson(geom_geojson) -def test_validate_geom_empty_coordinates(geom_geojson): - geom_geojson['coordinates'] = [] - _ = geojson.validate_geom(geom_geojson) +def test_validate_geom_as_geojson_empty_coordinates(geom_geojson): + geom_geojson["coordinates"] = [] + _ = geojson.validate_geom_as_geojson(geom_geojson) -def test_as_geom(geom_geojson): - assert geojson.as_geom(geom_geojson) == geom_geojson +def test_as_geom_or_ref(geom_geojson): + assert geojson.as_geom_or_ref(geom_geojson) == geom_geojson def test_as_polygon(geom_geojson): assert geojson.as_polygon(geom_geojson) == geom_geojson +def test_as_reference(geom_reference): + assert geojson.as_ref(geom_reference) == geom_reference + + def test_as_polygon_wrong_type(point_geom_geojson): with pytest.raises(exceptions.GeoJSONError): _ = geojson.as_polygon(point_geom_geojson) @@ -114,22 +118,22 @@ def test_as_polygon_wrong_type(point_geom_geojson): def test_as_featurecollection_success(feature_geojson): feature2 = feature_geojson.copy() - feature2['properties'] = {'foo': 'bar'} + feature2["properties"] = {"foo": "bar"} values = [feature_geojson, feature2] res = geojson.as_featurecollection(values) - expected = {'type': 'FeatureCollection', 'features': values} + expected = {"type": "FeatureCollection", "features": values} assert res == expected def test__is_instance_of_success(feature_geojson): - assert geojson._is_instance_of(feature_geojson, 'Feature') + assert geojson._is_instance_of(feature_geojson, "Feature") feature2 = feature_geojson.copy() - feature2['properties'] = {'foo': 'bar'} - assert geojson._is_instance_of(feature2, 'Feature') + feature2["properties"] = {"foo": "bar"} + assert geojson._is_instance_of(feature2, "Feature") def test__is_instance_of_does_not_exist(feature_geojson): with pytest.raises(exceptions.GeoJSONError): - geojson._is_instance_of(feature_geojson, 'Foobar') + geojson._is_instance_of(feature_geojson, "Foobar") From 8a9c4b6c7fcf1c06c3bfb4c09f0317137e1b416a Mon Sep 17 00:00:00 2001 From: Andy Gaither Date: Wed, 17 Apr 2024 15:01:26 -0600 Subject: [PATCH 2/5] create top level geom filter in data api allow feat refs for it --- planet/cli/data.py | 30 +++++++++++++---- planet/clients/data.py | 18 +++++++++- tests/integration/test_data_api.py | 54 ++++++++++++++++++++++++++++-- tests/integration/test_data_cli.py | 22 ++++++++++-- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/planet/cli/data.py b/planet/cli/data.py index 0c2958a37..8000927f2 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 7b8133a82..638988748 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 7a57f5396..a98e0dc3c 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 ddb04b289..aff36c2fc 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): From 0b2ce0752c669fbf68962ff2493d577c08378d62 Mon Sep 17 00:00:00 2001 From: Andy Gaither Date: Thu, 25 Apr 2024 13:49:06 -0600 Subject: [PATCH 3/5] Support feature refs as strings --- docs/cli/cli-tips-tricks.md | 60 +++++++++++---------- planet/cli/data.py | 20 ++++--- planet/cli/subscriptions.py | 19 ++++++- planet/cli/types.py | 14 +++++ planet/geojson.py | 58 +++++++++++++++----- planet/subscription_request.py | 8 +-- tests/conftest.py | 5 ++ tests/integration/test_data_cli.py | 7 ++- tests/integration/test_subscriptions_cli.py | 18 +++++-- tests/unit/test_geojson.py | 17 +++++- 10 files changed, 164 insertions(+), 62 deletions(-) diff --git a/docs/cli/cli-tips-tricks.md b/docs/cli/cli-tips-tricks.md index 81d4bfb83..c13b220bb 100644 --- a/docs/cli/cli-tips-tricks.md +++ b/docs/cli/cli-tips-tricks.md @@ -5,24 +5,24 @@ title: More CLI Tips & Tricks ## About This document shows off a range of more advanced command-line workflows, making use of a wider range -of tools in the command-line & geospatial ecosystem. Some of them can be a pain to install, like -GDAL/OGR, and several pop in and out of web tools, so these are kept out of the main tutorial -section. +of tools in the command-line & geospatial ecosystem. Some of them can be a pain to install, like +GDAL/OGR, and several pop in and out of web tools, so these are kept out of the main tutorial +section. **WORK IN PROGRESS**: This document is still under construction, with a number of TODO’s remaining, but we are publishing as there’s a lot of good information here. ## Tools used -* **[GDAL/OGR](https://gdal.org)** - We’ll mostly use OGR, the vector tooling. +* **[GDAL/OGR](https://gdal.org)** - We’ll mostly use OGR, the vector tooling. Great for things like format conversion and basic simplification. * **[Keplergl_cli](https://github.com/kylebarron/keplergl_cli#usage)** - Nice tool to call the -awesome kepler.gl library from the commandline. Useful for visualization of large amounts of +awesome kepler.gl library from the commandline. Useful for visualization of large amounts of geojson. * **[GeoJSON.io](https://geojson.io/)** - Simple tool to do editing of geojson, useful for creating AOI’s. It integrates with github, but the ability to save a GeoJSON to github doesn't seem to work so well. * **[Placemark.io](https://placemark.io)** - More advanced tool from the creator of GeoJSON.io, very -nice for creating AOI’s and piping them in, with lots of rich geometry editing features. +nice for creating AOI’s and piping them in, with lots of rich geometry editing features. * **[MapShaper](https://github.com/mbloch/mapshaper)** - Tool to do interactive simplification of GeoJSON, has a nice CLI. * **[STACTools](https://github.com/stac-utils/stactools)** - CLI for working with STAC data. There @@ -34,15 +34,15 @@ future. ### Geometry Inputs -While the command-line can often be quicker than using a UI, one place that can be slower is +While the command-line can often be quicker than using a UI, one place that can be slower is getting the geometry input for searching or clipping. Hand-editing GeoJSON is a huge pain, so most -people will open up a desktop tool like QGIS or ArcGIS Pro and save the file. But there are a few +people will open up a desktop tool like QGIS or ArcGIS Pro and save the file. But there are a few tools that can get you back into the CLI workflow more quickly. #### Use the Features API Rather than using GeoJSON in the SDK, upload your GeoJSON to the [Features API](https://developers.planet.com/docs/apis/features/) and use references across the system with the sdk. -References are used in the geometry block of our services like: +References are used in the geometry block of our services in a GeoJSON blob like: ```json "geometry": { @@ -50,10 +50,12 @@ References are used in the geometry block of our services like: "type": "ref" } ``` +Or as a string in a geometry option like `"pl:features/my/[collection-id]/[feature-id]"` + #### Draw with GeoJSON.io -One great tool for quickly drawing on a map and getting GeoJSON output is +One great tool for quickly drawing on a map and getting GeoJSON output is [GeoJSON.io](https://geojson.io). You can draw and save the file, but an even faster workflow is to use your operating system’s clipboard to command-line tools. @@ -74,7 +76,7 @@ pbpaste | planet data filter --geom - | planet data search SkySatCollect --filt A really fantastic tool for working with GeoJSON is [Placemark](https://placemark.io). It is a commercial tool that you’ll have to pay for, but it’s got a really nice feature that makes it very -compatible with command-line workflows. You can easily grab the URL of any individual GeoJSON +compatible with command-line workflows. You can easily grab the URL of any individual GeoJSON feature and stream it in as your geometry using `curl`: ![Stream from Placemark](https://user-images.githubusercontent.com/407017/179412209-2365d79a-9260-47e5-9b08-9bc5b84b6ddc.gif) @@ -92,8 +94,8 @@ let you pipe (`|`) the output more directly. #### Copy GeoJSON to clipboard -One of the quicker routes to visualizing search output is to copy the output to your clipboard and paste into a -tool that will take GeoJSON and visualize it. +One of the quicker routes to visualizing search output is to copy the output to your clipboard and paste into a +tool that will take GeoJSON and visualize it. You can do this on GeoJSON.io: @@ -113,7 +115,7 @@ planet data filter --string-in strip_id 5743669 | planet data search PSScene --f #### Post to Github as gist -Another easy option that is a bit more persistent is to post to Github using the +Another easy option that is a bit more persistent is to post to Github using the [`gh` cli tool](https://github.com/cli/cli). Specifically using the `gist create` command. The following command will get the latest SkySat image captured, upload to github, and open @@ -147,13 +149,13 @@ planet data filter --string-in strip_id $stripid | planet data search PSScene -- One of the best tools to visualize large numbers of imagery footprints is a tool called [kepler.gl](https://kepler.gl/), which has a really awesome command-line version which is perfect for working with Planet’s CLI. To get the CLI go to -[keplergl_cli](https://github.com/kylebarron/keplergl_cli) and follow the +[keplergl_cli](https://github.com/kylebarron/keplergl_cli) and follow the [installation instructions](https://github.com/kylebarron/keplergl_cli#install). Be sure to get a Mapbox API key (from the [access tokens](https://account.mapbox.com/access-tokens/) page) - just sign up for a free account if you don't have one already. The kepler CLI won't work at all without getting one and setting it as the `MAPBOX_API_KEY` environment variable. -Once it’s set up you can just pipe any search command directly to `kepler` (it usually does fine even without +Once it’s set up you can just pipe any search command directly to `kepler` (it usually does fine even without `planet collect` to go from ndgeojson to geojson). For example: ```console @@ -191,9 +193,9 @@ curl -s https://api.placemark.io/api/v1/map/a0BWUEErqU9A1EDHZWHez/feature/91a073 #### Large Dataset Visualization -Oftentimes it can be useful to visualize a large amount of data, to really get a sense of the -coverage and then do some filtering of the output. For this we recommend downloading the output -to disk. Getting 20,000 skysat collects will take at least a couple of minutes, and will be over +Oftentimes it can be useful to visualize a large amount of data, to really get a sense of the +coverage and then do some filtering of the output. For this we recommend downloading the output +to disk. Getting 20,000 skysat collects will take at least a couple of minutes, and will be over 100 megabytes of GeoJSON on disk. ```console @@ -267,7 +269,7 @@ Smaller ratios preserve the character of concave features better. ##### Simplification with OGR -The other thing you’ll likely want to do to visualize large amounts of data is to simplify it +The other thing you’ll likely want to do to visualize large amounts of data is to simplify it some. Many simplification tools call for a 'tolerance', often set in degrees. For SkySat some useful values are: | tolerance | result | @@ -276,7 +278,7 @@ some. Many simplification tools call for a 'tolerance', often set in degrees. Fo | 0.01 | Messes with the shape a bit, but the footprint generally looks the same, with a couple vertices off. | | 0.1 | Mashes the shape, often into a triangle, but still useful for understanding broad coverage. | -It’s worth experimenting with options between these as well. The more simplification the easier it is for programs to +It’s worth experimenting with options between these as well. The more simplification the easier it is for programs to render the results. `ogr2ogr` includes the ability to simplify any output: ```console @@ -289,14 +291,14 @@ Alternative - use convex hull. TODO: test this, write it up ogr2ogr skysat-convex.gpkg skysat.geojson ogr2ogr -sql "select st_convexhull(geometry) from skysat" -dialect sqlite ``` -Other alternative for really big ones, centroid. GDAL should be able to do this, need to figure out the similar +Other alternative for really big ones, centroid. GDAL should be able to do this, need to figure out the similar sql. #### Simplification with Mapshaper -Another great tool is [Mapshaper](https://github.com/mbloch/mapshaper), which excels at simplification. It offers a -web-based user interface to see the results of simplification, and also a command-line tool you can use if you -find a simplification percentage you’re happy with. After you get it +Another great tool is [Mapshaper](https://github.com/mbloch/mapshaper), which excels at simplification. It offers a +web-based user interface to see the results of simplification, and also a command-line tool you can use if you +find a simplification percentage you’re happy with. After you get it [installed](https://github.com/mbloch/mapshaper#installation) you can fire up the UI with: ```console @@ -312,13 +314,13 @@ interface, or you can also run the command-line program: mapshaper -i footprints.geojson -simplify 15% -o simplified.geojson ``` -Once you find a simplification amount you’re happy with you can use it as a piped output. +Once you find a simplification amount you’re happy with you can use it as a piped output. ```console planet data search --limit 20 SkySatCollect - | planet collect - | mapshaper -i - -simplify 15% -o skysat-ms2.geojson ``` -Mapshaper also has more simplification algorithms to try out, so we recommend diving into the +Mapshaper also has more simplification algorithms to try out, so we recommend diving into the [CLI options](https://github.com/mbloch/mapshaper/wiki/Command-Reference). #### Simplification with QGIS @@ -328,7 +330,7 @@ Another good tool for simplification is QGIS. TODO: Flesh out this section, add in command-line qgis_processing option. Other simplification options for large datasets: - + * Use QGIS, run 'convex hull' (Vector -> Geoprocessing -> Convex Hull). Good idea to convert to gpkg or shapefile before you open in qgis if large. ### Advanced jq @@ -339,7 +341,7 @@ Other simplification options for large datasets: - get id by array number ```console -planet orders list | jq -rs '.[3] | "\(.id) \(.created_on) \(.name) \(.state)"' +planet orders list | jq -rs '.[3] | "\(.id) \(.created_on) \(.name) \(.state)"' ``` (limit can get the most recent, but not a second or third) diff --git a/planet/cli/data.py b/planet/cli/data.py index 8000927f2..428899835 100644 --- a/planet/cli/data.py +++ b/planet/cli/data.py @@ -15,7 +15,6 @@ from typing import List, Optional from contextlib import asynccontextmanager from pathlib import Path - import click from planet.reporting import AssetStatusBar @@ -81,9 +80,15 @@ 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]: +def check_geom(ctx, param, geometry) -> Optional[dict]: """Validates geometry as GeoJSON or feature ref(s).""" - return geojson.as_geom_or_ref(geometry) if geometry else None + if isinstance(geometry, dict): + return geojson.as_geom_or_ref(geometry) + geoms = {} + if geometry: + for geom in geometry: + geoms.update(geojson.as_geom_or_ref(geom)) + return geoms if geoms else None def check_item_type(ctx, param, item_type) -> Optional[List[dict]]: @@ -286,7 +291,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("--geom", type=types.Geometry(), callback=check_geom) @click.option('--filter', type=types.JSON(), help="""Apply specified filter to search. Can be a json string, @@ -332,7 +337,7 @@ async def search(ctx, item_types, geom, 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("--geom", type=types.Geometry(), callback=check_geom) @click.option( '--filter', type=types.JSON(), @@ -500,7 +505,10 @@ 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("--geom", + type=types.Geometry(), + callback=check_geom, + default=None) @click.option('--daily-email', is_flag=True, help='Send a daily email when new results are added.') diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 26df617b6..f9e8d1696 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -13,6 +13,7 @@ from .. import subscription_request from ..subscription_request import sentinel_hub from ..specs import get_item_types, validate_item_type, SpecificationException +from planet import geojson ALL_ITEM_TYPES = get_item_types() valid_item_string = "Valid entries for ITEM_TYPES: " + "|".join(ALL_ITEM_TYPES) @@ -29,6 +30,17 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]: raise click.BadParameter(str(e)) +def check_geom(ctx, param, geometry) -> Optional[dict]: + """Validates geometry as GeoJSON or feature ref(s).""" + if isinstance(geometry, dict): + return geojson.as_geom_or_ref(geometry) + geoms = {} + if geometry: + for geom in geometry: + geoms.update(geojson.as_geom_or_ref(geom)) + return geoms if geoms 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.""" @@ -346,7 +358,8 @@ def request(name, @click.option( '--geometry', required=True, - type=types.JSON(), + type=types.Geometry(), + callback=check_geom, help="""Geometry of the area of interest of the subscription that will be used to determine matches. Can be a string, filename, or - for stdin.""") @click.option('--start-time', @@ -418,7 +431,8 @@ def request_catalog(item_types, @click.option( '--geometry', required=True, - type=types.JSON(), + type=types.Geometry(), + callback=check_geom, help="""Geometry of the area of interest of the subscription that will be used to determine matches. Can be a string, filename, or - for stdin.""") @click.option('--start-time', @@ -437,6 +451,7 @@ def request_pv(var_type, var_id, geometry, start_time, end_time, pretty): Variables](https://developers.planet.com/docs/subscriptions/pvs-subs/#planetary-variables-types-and-ids) for details. """ + # print("Geom is", geometry) res = subscription_request.planetary_variable_source( var_type, var_id, diff --git a/planet/cli/types.py b/planet/cli/types.py index 94ced2359..6032fe709 100644 --- a/planet/cli/types.py +++ b/planet/cli/types.py @@ -93,6 +93,20 @@ def convert(self, value, param, ctx) -> dict: return convdict +class Geometry(click.ParamType): + name = 'geom' + + def __init__(self): + self.types = [JSON(), CommaSeparatedString()] + + def convert(self, value, param, ctx): + for type in self.types: + try: + return type.convert(value, param, ctx) + except click.BadParameter: + continue + + class Field(click.ParamType): """Clarify that this entry is for a field""" name = 'field' diff --git a/planet/geojson.py b/planet/geojson.py index 77bb3247e..ac76ae4b5 100644 --- a/planet/geojson.py +++ b/planet/geojson.py @@ -19,7 +19,6 @@ import geojson as gj from jsonschema import Draft7Validator - from .constants import DATA_DIR from .exceptions import GeoJSONError, FeatureError @@ -28,7 +27,7 @@ LOGGER = logging.getLogger(__name__) -def as_geom_or_ref(data: dict) -> dict: +def as_geom_or_ref(data) -> dict: """Extract the geometry from GeoJSON and validate. Parameters: @@ -42,6 +41,8 @@ def as_geom_or_ref(data: dict) -> dict: or FeatureCollection or if more than one Feature is in a FeatureCollection. """ + if isinstance(data, str): + return as_ref(data) geom_type = data['type'] if geom_type == 'ref': return as_ref(data) @@ -51,16 +52,49 @@ def as_geom_or_ref(data: dict) -> dict: return geom -def as_ref(data: dict) -> dict: - geom_type = data['type'] - if geom_type.lower() != 'ref': - raise FeatureError( - f'Invalid geometry reference: {geom_type} is not a reference (the type should be "ref").' - ) - if "content" not in data: - raise FeatureError( - 'Invalid geometry reference: Missing content block that contains the reference.' - ) +def validate_ref(uri) -> bool: + if uri is None: + raise FeatureError("Expected str, not None") + parts = uri.split("/", 4) + if parts[0] != "pl:features": + raise FeatureError("Expected scheme pl:features") + path = parts[1:] + if len(path) < 2: + raise FeatureError("Expceted dataset/collection path") + return True + + +def convert_ref_to_dict(data: str) -> dict: + """ Ensure geom reference is in the expected format + Then convert it into a geometry block + + Parameters: + data: str, a feature reference + Returns: + GeoJSON geometry reference + """ + if validate_ref(data): + geom = { + "type": "ref", + "content": data, + } + return geom + return dict() + + +def as_ref(data) -> dict: + if isinstance(data, str): + data = convert_ref_to_dict(data) + if isinstance(data, dict): + geom_type = data['type'] + if geom_type.lower() != 'ref': + raise FeatureError( + f'Invalid geometry reference: {geom_type} is not a reference (the type should be "ref").' + ) + if "content" not in data: + raise FeatureError( + 'Invalid geometry reference: Missing content block that contains the reference.' + ) return data diff --git a/planet/subscription_request.py b/planet/subscription_request.py index 361b3d71e..88d13df7a 100644 --- a/planet/subscription_request.py +++ b/planet/subscription_request.py @@ -167,7 +167,7 @@ def build_request(name: str, def catalog_source( item_types: List[str], asset_types: List[str], - geometry: Mapping, + geometry: dict, start_time: datetime, filter: Optional[Mapping] = None, end_time: Optional[datetime] = None, @@ -250,7 +250,7 @@ def catalog_source( parameters = { "item_types": item_types, "asset_types": asset_types, - "geometry": geojson.as_geom_or_ref(dict(geometry)), + "geometry": geojson.as_geom_or_ref(geometry), } try: @@ -287,7 +287,7 @@ def planetary_variable_source( "forest_carbon_diligence_30m", "field_boundaries_sentinel_2_p1m"], var_id: str, - geometry: Mapping, + geometry: dict, start_time: datetime, end_time: Optional[datetime] = None, ) -> dict: @@ -355,7 +355,7 @@ def planetary_variable_source( parameters = { "id": var_id, - "geometry": geojson.as_geom_or_ref(dict(geometry)), + "geometry": geojson.as_geom_or_ref(geometry), } try: diff --git a/tests/conftest.py b/tests/conftest.py index 79f851ba5..028cb17d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,6 +121,11 @@ def geom_geojson(): } # yapf: disable +@pytest.fixture +def str_geom_reference(): + return "pl:features/my/water-fields-RqB0NZ5/rmQEGqm" + + @pytest.fixture def geom_reference(): return { diff --git a/tests/integration/test_data_cli.py b/tests/integration/test_data_cli.py index aff36c2fc..bc6cb0f52 100644 --- a/tests/integration/test_data_cli.py +++ b/tests/integration/test_data_cli.py @@ -436,7 +436,8 @@ def test_data_search_cmd_item_types(item_types, expect_success, invoke): @respx.mock @pytest.mark.parametrize("geom_fixture", [('geom_geojson'), ('feature_geojson'), - ('featurecollection_geojson'), ('geom_reference')]) + ('featurecollection_geojson'), ('geom_reference'), + ("str_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""" @@ -446,8 +447,10 @@ def test_data_search_cmd_top_level_geom(geom_fixture, request, invoke): }]}) respx.post(TEST_QUICKSEARCH_URL).return_value = mock_resp geom = request.getfixturevalue(geom_fixture) + if isinstance(geom, dict): + geom = json.dumps(geom) - result = invoke(["search", 'PSScene', f"--geom={json.dumps(geom)}"]) + result = invoke(["search", 'PSScene', f"--geom={geom}"]) assert result.exit_code == 0 diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py index fd276e6c6..56e9d5d5a 100644 --- a/tests/integration/test_subscriptions_cli.py +++ b/tests/integration/test_subscriptions_cli.py @@ -321,12 +321,17 @@ def test_request_base_success(invoke, geom_geojson): assert result.exit_code == 0 # success. -def test_request_base_clip_to_source(invoke, geom_geojson): +@pytest.mark.parametrize("geom_fixture", + [('geom_geojson'), ('geom_reference'), + ("str_geom_reference")]) +def test_request_base_clip_to_source(geom_fixture, request, invoke): """Clip to source using command line option.""" + geom = request.getfixturevalue(geom_fixture) + print("geom is", geom) source = json.dumps({ "type": "catalog", "parameters": { - "geometry": geom_geojson, + "geometry": geom, "start_time": "2021-03-01T00:00:00Z", "item_types": ["PSScene"], "asset_types": ["ortho_analytic_4b"] @@ -344,7 +349,7 @@ def test_request_base_clip_to_source(invoke, geom_geojson): req = json.loads(result.output) tool = req["tools"][0] assert tool["type"] == "clip" - assert tool["parameters"]["aoi"] == geom_geojson + assert tool["parameters"]["aoi"] == geom def test_request_catalog_success(invoke, geom_geojson): @@ -378,15 +383,18 @@ def test_subscriptions_results_csv(invoke): assert result.output.splitlines() == ["id,status", "1234-abcd,SUCCESS"] -@pytest.mark.parametrize("geom", ["geom_geojson", "geom_reference"]) +@pytest.mark.parametrize( + "geom", ["geom_geojson", "geom_reference", "str_geom_reference"]) def test_request_pv_success(invoke, geom, request): """Request-pv command succeeds""" geom = request.getfixturevalue(geom) + if isinstance(geom, dict): + geom = json.dumps(geom) result = invoke([ "request-pv", "--var-type=biomass_proxy", "--var-id=BIOMASS-PROXY_V3.0_10", - f"--geometry={json.dumps(geom)}", + f"--geometry={geom}", "--start-time=2021-03-01T00:00:00", ]) diff --git a/tests/unit/test_geojson.py b/tests/unit/test_geojson.py index 9ed1a4dce..38d9c6b6c 100644 --- a/tests/unit/test_geojson.py +++ b/tests/unit/test_geojson.py @@ -99,7 +99,7 @@ def test_validate_geom_as_geojson_empty_coordinates(geom_geojson): _ = geojson.validate_geom_as_geojson(geom_geojson) -def test_as_geom_or_ref(geom_geojson): +def test_as_geojson(geom_geojson): assert geojson.as_geom_or_ref(geom_geojson) == geom_geojson @@ -107,10 +107,23 @@ def test_as_polygon(geom_geojson): assert geojson.as_polygon(geom_geojson) == geom_geojson -def test_as_reference(geom_reference): +def test_as_ref(geom_reference): assert geojson.as_ref(geom_reference) == geom_reference +def test_as_str_ref(str_geom_reference): + geomify_ref = { + "type": "ref", + "content": str_geom_reference, + } + assert geojson.as_ref(str_geom_reference) == geomify_ref + + +def test_as_invalid_ref(): + with pytest.raises(exceptions.FeatureError): + geojson.as_ref("some:nonesense/with/nothing") + + def test_as_polygon_wrong_type(point_geom_geojson): with pytest.raises(exceptions.GeoJSONError): _ = geojson.as_polygon(point_geom_geojson) From f3bbee9cd78854622a9602a62872aa93e6de0e1d Mon Sep 17 00:00:00 2001 From: Andy Gaither Date: Mon, 20 May 2024 11:03:20 -0600 Subject: [PATCH 4/5] pr comments --- planet/cli/subscriptions.py | 1 - tests/integration/test_orders_cli.py | 34 +++++++++++++++++++++ tests/integration/test_subscriptions_cli.py | 5 ++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index f9e8d1696..c48aa3925 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -451,7 +451,6 @@ def request_pv(var_type, var_id, geometry, start_time, end_time, pretty): Variables](https://developers.planet.com/docs/subscriptions/pvs-subs/#planetary-variables-types-and-ids) for details. """ - # print("Geom is", geometry) res = subscription_request.planetary_variable_source( var_type, var_id, diff --git a/tests/integration/test_orders_cli.py b/tests/integration/test_orders_cli.py index 52d544681..7bc057ba8 100644 --- a/tests/integration/test_orders_cli.py +++ b/tests/integration/test_orders_cli.py @@ -555,6 +555,40 @@ def test_cli_orders_request_clip_polygon(geom_fixture, assert order_request == json.loads(result.output) +@pytest.mark.parametrize("geom_fixture", [('geom_reference')]) +def test_cli_orders_request_clip_ref(geom_fixture, request, invoke, stac_json): + + geom = request.getfixturevalue(geom_fixture) + + result = invoke([ + 'request', + '--item-type=PSOrthoTile', + '--bundle=analytic', + '--name=test', + '4500474_2133707_2021-05-20_2419', + f'--clip={json.dumps(geom)}', + ]) + assert result.exit_code == 0 + + order_request = { + "name": + "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "tools": [{ + 'clip': { + 'aoi': geom + } + }], + "metadata": + stac_json + } + assert order_request == json.loads(result.output) + + def test_cli_orders_request_clip_multipolygon(multipolygon_geom_geojson, invoke, geom_geojson, diff --git a/tests/integration/test_subscriptions_cli.py b/tests/integration/test_subscriptions_cli.py index 56e9d5d5a..0349af73d 100644 --- a/tests/integration/test_subscriptions_cli.py +++ b/tests/integration/test_subscriptions_cli.py @@ -327,15 +327,14 @@ def test_request_base_success(invoke, geom_geojson): def test_request_base_clip_to_source(geom_fixture, request, invoke): """Clip to source using command line option.""" geom = request.getfixturevalue(geom_fixture) - print("geom is", geom) source = json.dumps({ "type": "catalog", "parameters": { "geometry": geom, "start_time": "2021-03-01T00:00:00Z", "item_types": ["PSScene"], - "asset_types": ["ortho_analytic_4b"] - } + "asset_types": ["ortho_analytic_4b"], + }, }) result = invoke([ 'request', From 862d92fc9a998e43e99c6d82074a03cb93ef28b6 Mon Sep 17 00:00:00 2001 From: Andy Gaither Date: Mon, 20 May 2024 16:55:09 -0600 Subject: [PATCH 5/5] Move check_geom to validators --- planet/cli/data.py | 14 ++------------ planet/cli/subscriptions.py | 13 +------------ planet/cli/validators.py | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 planet/cli/validators.py diff --git a/planet/cli/data.py b/planet/cli/data.py index 428899835..916598ea2 100644 --- a/planet/cli/data.py +++ b/planet/cli/data.py @@ -18,7 +18,7 @@ import click from planet.reporting import AssetStatusBar -from planet import data_filter, DataClient, exceptions, geojson +from planet import data_filter, DataClient, exceptions from planet.clients.data import (SEARCH_SORT, LIST_SEARCH_TYPE, LIST_SEARCH_TYPE_DEFAULT, @@ -36,6 +36,7 @@ from .io import echo_json from .options import limit, pretty from .session import CliSession +from .validators import check_geom valid_item_string = "Valid entries for ITEM_TYPES: " + "|".join( get_data_item_types()) @@ -80,17 +81,6 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]: raise click.BadParameter(str(e)) -def check_geom(ctx, param, geometry) -> Optional[dict]: - """Validates geometry as GeoJSON or feature ref(s).""" - if isinstance(geometry, dict): - return geojson.as_geom_or_ref(geometry) - geoms = {} - if geometry: - for geom in geometry: - geoms.update(geojson.as_geom_or_ref(geom)) - return geoms if geoms 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.""" diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index c48aa3925..50bdfe966 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -13,7 +13,7 @@ from .. import subscription_request from ..subscription_request import sentinel_hub from ..specs import get_item_types, validate_item_type, SpecificationException -from planet import geojson +from .validators import check_geom ALL_ITEM_TYPES = get_item_types() valid_item_string = "Valid entries for ITEM_TYPES: " + "|".join(ALL_ITEM_TYPES) @@ -30,17 +30,6 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]: raise click.BadParameter(str(e)) -def check_geom(ctx, param, geometry) -> Optional[dict]: - """Validates geometry as GeoJSON or feature ref(s).""" - if isinstance(geometry, dict): - return geojson.as_geom_or_ref(geometry) - geoms = {} - if geometry: - for geom in geometry: - geoms.update(geojson.as_geom_or_ref(geom)) - return geoms if geoms 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.""" diff --git a/planet/cli/validators.py b/planet/cli/validators.py new file mode 100644 index 000000000..9a325365c --- /dev/null +++ b/planet/cli/validators.py @@ -0,0 +1,27 @@ +# Copyright 2022 Planet Labs, PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +"""CLI Parameter validation""" +from typing import Optional +from planet import geojson + + +def check_geom(ctx, param, geometry) -> Optional[dict]: + """Validates geometry as GeoJSON or feature ref(s).""" + if isinstance(geometry, dict): + return geojson.as_geom_or_ref(geometry) + geoms = {} + if geometry: + for geom in geometry: + geoms.update(geojson.as_geom_or_ref(geom)) + return geoms if geoms else None