diff --git a/docs/cli/cli-tips-tricks.md b/docs/cli/cli-tips-tricks.md index fc0da694..81d4bfb8 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 6bc44862..ef7d10f0 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 5e44486a..eee852bd 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 87fc8429..77bb3247 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 0d4b6030..074d7bdd 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 63bdcd93..361b3d71 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 1013ae62..79f851ba 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 ded9d3dd..fd276e6c 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 c5f3f6f5..9ed1a4dc 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")