Skip to content

Commit 5eafa44

Browse files
committed
Issue #211 add load_geojson
1 parent 1af1fdf commit 5eafa44

File tree

5 files changed

+280
-4
lines changed

5 files changed

+280
-4
lines changed

openeo_driver/ProcessGraphDeserializer.py

+8
Original file line numberDiff line numberDiff line change
@@ -1589,6 +1589,14 @@ def to_vector_cube(args: Dict, env: EvalEnv):
15891589
raise FeatureUnsupportedException(f"Converting {type(data)} to vector cube is not supported")
15901590

15911591

1592+
@process_registry_100.add_function(spec=read_spec("openeo-processes/2.x/proposals/load_geojson.json"))
1593+
def load_geojson(args: ProcessArgs, env: EvalEnv) -> DriverVectorCube:
1594+
data = args.get_required("data", validator=ProcessArgs.validator_geojson_dict())
1595+
properties = args.get_optional("properties", default=[], expected_type=(list, tuple))
1596+
vector_cube = env.backend_implementation.vector_cube_cls.from_geojson(data, columns_for_cube=properties)
1597+
return vector_cube
1598+
1599+
15921600
@non_standard_process(
15931601
ProcessSpec("get_geometries", description="Reads vector data from a file or a URL or get geometries from a FeatureCollection")
15941602
.param('filename', description="filename or http url of a vector file", schema={"type": "string"}, required=False)

openeo_driver/processes.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import functools
22
import inspect
3-
import typing
43
import warnings
54
from collections import namedtuple
65
from pathlib import Path
7-
from typing import Callable, Dict, List, Tuple, Optional, Any, Union
6+
from typing import Callable, Dict, List, Tuple, Optional, Any, Union, Collection
87

98
from openeo_driver.errors import (
109
ProcessUnsupportedException,
1110
ProcessParameterRequiredException,
1211
ProcessParameterInvalidException,
1312
)
1413
from openeo_driver.specs import SPECS_ROOT
14+
from openeo_driver.util.geometry import validate_geojson_basic
1515
from openeo_driver.utils import read_json, EvalEnv
1616

1717

@@ -411,7 +411,7 @@ def get_subset(self, names: List[str], aliases: Optional[Dict[str, str]] = None)
411411
kwargs[key] = self[alias]
412412
return kwargs
413413

414-
def get_enum(self, name: str, options: typing.Container[ArgumentValue]) -> ArgumentValue:
414+
def get_enum(self, name: str, options: Collection[ArgumentValue]) -> ArgumentValue:
415415
"""
416416
Get argument by name and check if it belongs to given set of (enum) values.
417417
@@ -440,3 +440,17 @@ def validator(value):
440440
return True
441441

442442
return validator
443+
444+
@staticmethod
445+
def validator_geojson_dict(
446+
allowed_types: Optional[Collection[str]] = None,
447+
):
448+
"""Build validator to verify that provided structure looks like a GeoJSON-style object"""
449+
450+
def validator(value):
451+
issues = validate_geojson_basic(value=value, allowed_types=allowed_types, raise_exception=False)
452+
if issues:
453+
raise ValueError(f"Invalid GeoJSON: {', '.join(issues)}.")
454+
return True
455+
456+
return validator

openeo_driver/util/geometry.py

+62-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import re
55
from pathlib import Path
6-
from typing import Union, Tuple, Optional, List, Mapping, Sequence
6+
from typing import Union, Tuple, Optional, List, Mapping, Sequence, Any, Collection
77

88
import pyproj
99
import shapely.geometry
@@ -17,6 +17,67 @@
1717
_log = logging.getLogger(__name__)
1818

1919

20+
GEOJSON_GEOMETRY_TYPES_BASIC = frozenset(
21+
{"Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"}
22+
)
23+
GEOJSON_GEOMETRY_TYPES_EXTENDED = GEOJSON_GEOMETRY_TYPES_BASIC | {"GeometryCollection"}
24+
25+
26+
def validate_geojson_basic(
27+
value: Any,
28+
*,
29+
allowed_types: Optional[Collection[str]] = None,
30+
raise_exception: bool = True,
31+
recurse: bool = True,
32+
) -> List[str]:
33+
"""
34+
Validate if given value looks like a valid GeoJSON construct.
35+
36+
Note: this is just for basic inspection to catch simple/obvious structural issues.
37+
It is not intended for a full-blown, deep GeoJSON validation and coordinate inspection.
38+
39+
:param value: the value to inspect
40+
:param allowed_types: optional collection of GeoJSON types to accept
41+
:param raise_exception: whether to raise an exception when issues are found (default),
42+
or just return list of issues
43+
:param recurse: whether to recursively validate Feature's geometry and FeatureCollection's features
44+
:returns: list of issues found (when `raise_exception` is off)
45+
"""
46+
try:
47+
if not isinstance(value, dict):
48+
raise ValueError(f"JSON object (mapping/dictionary) expected, but got {type(value).__name__}")
49+
assert "type" in value, "No 'type' field"
50+
geojson_type = value["type"]
51+
assert isinstance(geojson_type, str), f"Invalid 'type' type: {type(geojson_type).__name__}"
52+
if allowed_types and geojson_type not in allowed_types:
53+
raise ValueError(f"Found type {geojson_type!r}, but expects one of {sorted(allowed_types)}")
54+
if geojson_type in GEOJSON_GEOMETRY_TYPES_BASIC:
55+
assert "coordinates" in value, f"No 'coordinates' field (type {geojson_type!r})"
56+
elif geojson_type in {"GeometryCollection"}:
57+
assert "geometries" in value, f"No 'geometries' field (type {geojson_type!r})"
58+
# TODO: recursively check sub-geometries?
59+
elif geojson_type in {"Feature"}:
60+
assert "geometry" in value, f"No 'geometry' field (type {geojson_type!r})"
61+
assert "properties" in value, f"No 'properties' field (type {geojson_type!r})"
62+
if recurse:
63+
validate_geojson_basic(
64+
value["geometry"], recurse=True, allowed_types=GEOJSON_GEOMETRY_TYPES_EXTENDED, raise_exception=True
65+
)
66+
elif geojson_type in {"FeatureCollection"}:
67+
assert "features" in value, f"No 'features' field (type {geojson_type!r})"
68+
if recurse:
69+
for f in value["features"]:
70+
validate_geojson_basic(f, recurse=True, allowed_types=["Feature"], raise_exception=True)
71+
else:
72+
raise ValueError(f"Invalid type {geojson_type!r}")
73+
74+
except Exception as e:
75+
if raise_exception:
76+
raise
77+
return [str(e)]
78+
return []
79+
80+
2081
def validate_geojson_coordinates(geojson):
2182
def _validate_coordinates(coordinates, initial_run=True):
2283
max_evaluations = 20

tests/test_processes.py

+23
Original file line numberDiff line numberDiff line change
@@ -612,3 +612,26 @@ def test_get_enum(self):
612612
),
613613
):
614614
_ = args.get_enum("color", options=["R", "G", "B"])
615+
616+
def test_validator_geojson_dict(self):
617+
polygon = {"type": "Polygon", "coordinates": [[1, 2]]}
618+
args = ProcessArgs({"geometry": polygon, "color": "red"}, process_id="wibble")
619+
620+
validator = ProcessArgs.validator_geojson_dict()
621+
assert args.get_required("geometry", validator=validator) == polygon
622+
with pytest.raises(
623+
ProcessParameterInvalidException,
624+
match=re.escape(
625+
"The value passed for parameter 'color' in process 'wibble' is invalid: Invalid GeoJSON: JSON object (mapping/dictionary) expected, but got str."
626+
),
627+
):
628+
_ = args.get_required("color", validator=validator)
629+
630+
validator = ProcessArgs.validator_geojson_dict(allowed_types=["FeatureCollection"])
631+
with pytest.raises(
632+
ProcessParameterInvalidException,
633+
match=re.escape(
634+
"The value passed for parameter 'geometry' in process 'wibble' is invalid: Invalid GeoJSON: Found type 'Polygon', but expects one of ['FeatureCollection']."
635+
),
636+
):
637+
_ = args.get_required("geometry", validator=validator)

tests/util/test_geometry.py

+170
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import contextlib
2+
from typing import Union, List
3+
14
import math
25

36
import pyproj
@@ -19,6 +22,7 @@
1922
BoundingBox,
2023
BoundingBoxException,
2124
CrsRequired,
25+
validate_geojson_basic,
2226
)
2327

2428

@@ -746,3 +750,169 @@ def test_best_utm(self):
746750

747751
bbox = BoundingBox(-72, -13, -71, -12, crs="EPSG:4326")
748752
assert bbox.best_utm() == 32719
753+
754+
755+
class TestValidateGeoJSON:
756+
@staticmethod
757+
@contextlib.contextmanager
758+
def _checker(expected_issue: Union[str, None], raise_exception: bool):
759+
"""
760+
Helper context manager to easily check a validate_geojson_basic result
761+
for both raise_exception modes:
762+
763+
- "exception mode": context manger __exit__ phase checks result
764+
- "return issue mode": returned `check` function should be used inside context manageer body
765+
"""
766+
checked = False
767+
768+
def check(result: List[str]):
769+
"""Check validation result in case no actual exception was thrown"""
770+
nonlocal checked
771+
checked = True
772+
if expected_issue:
773+
if raise_exception:
774+
pytest.fail("Exception should have been raised")
775+
if not result:
776+
pytest.fail("No issue was reported")
777+
assert expected_issue in "\n".join(result)
778+
else:
779+
if result:
780+
pytest.fail(f"Unexpected issue reported: {result}")
781+
782+
try:
783+
yield check
784+
except Exception as e:
785+
# Check validation result in case of actual exception
786+
if not raise_exception:
787+
pytest.fail(f"Unexpected {e!r}: issue should be returned")
788+
if not expected_issue:
789+
pytest.fail(f"Unexpected {e!r}: no issue expected")
790+
assert expected_issue in str(e)
791+
else:
792+
# No exception was thrown: check that the `check` function has been called.
793+
if not checked:
794+
raise RuntimeError("`check` function was not used")
795+
796+
@pytest.mark.parametrize(
797+
["value", "expected_issue"],
798+
[
799+
("nope nope", "JSON object (mapping/dictionary) expected, but got str"),
800+
(123, "JSON object (mapping/dictionary) expected, but got int"),
801+
({}, "No 'type' field"),
802+
({"type": 123}, "Invalid 'type' type: int"),
803+
({"type": {"Poly": "gon"}}, "Invalid 'type' type: dict"),
804+
({"type": "meh"}, "Invalid type 'meh'"),
805+
({"type": "Point"}, "No 'coordinates' field (type 'Point')"),
806+
({"type": "Point", "coordinates": [1, 2]}, None),
807+
({"type": "Polygon"}, "No 'coordinates' field (type 'Polygon')"),
808+
({"type": "Polygon", "coordinates": [[1, 2]]}, None),
809+
({"type": "MultiPolygon"}, "No 'coordinates' field (type 'MultiPolygon')"),
810+
({"type": "MultiPolygon", "coordinates": [[[1, 2]]]}, None),
811+
({"type": "GeometryCollection", "coordinates": []}, "No 'geometries' field (type 'GeometryCollection')"),
812+
({"type": "GeometryCollection", "geometries": []}, None),
813+
({"type": "Feature", "coordinates": []}, "No 'geometry' field (type 'Feature')"),
814+
({"type": "Feature", "geometry": {}}, "No 'properties' field (type 'Feature')"),
815+
({"type": "Feature", "geometry": {}, "properties": {}}, "No 'type' field"),
816+
(
817+
{"type": "Feature", "geometry": {"type": "Polygon"}, "properties": {}},
818+
"No 'coordinates' field (type 'Polygon')",
819+
),
820+
(
821+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1, 2]]}, "properties": {}},
822+
None,
823+
),
824+
(
825+
{"type": "Feature", "geometry": {"type": "Polygonnnnn", "coordinates": [[1, 2]]}, "properties": {}},
826+
"Found type 'Polygonnnnn', but expects one of ",
827+
),
828+
({"type": "FeatureCollection"}, "No 'features' field (type 'FeatureCollection')"),
829+
({"type": "FeatureCollection", "features": []}, None),
830+
({"type": "FeatureCollection", "features": [{"type": "Feature"}]}, "No 'geometry' field (type 'Feature')"),
831+
(
832+
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {}}]},
833+
"No 'properties' field (type 'Feature')",
834+
),
835+
(
836+
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {}, "properties": {}}]},
837+
"No 'type' field",
838+
),
839+
(
840+
{
841+
"type": "FeatureCollection",
842+
"features": [{"type": "Feature", "geometry": {"type": "Polygon"}, "properties": {}}],
843+
},
844+
"No 'coordinates' field (type 'Polygon')",
845+
),
846+
(
847+
{
848+
"type": "FeatureCollection",
849+
"features": [
850+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1, 2]]}, "properties": {}},
851+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[3, 4]]}, "properties": {}},
852+
],
853+
},
854+
None,
855+
),
856+
],
857+
)
858+
@pytest.mark.parametrize("raise_exception", [False, True])
859+
def test_validate_geojson_basic(self, value, expected_issue, raise_exception):
860+
with self._checker(expected_issue=expected_issue, raise_exception=raise_exception) as check:
861+
result = validate_geojson_basic(value, raise_exception=raise_exception)
862+
check(result)
863+
864+
@pytest.mark.parametrize(
865+
["value", "allowed_types", "expected_issue"],
866+
[
867+
(
868+
{"type": "Point", "coordinates": [1, 2]},
869+
{"Polygon", "MultiPolygon"},
870+
"Found type 'Point', but expects one of ['MultiPolygon', 'Polygon']",
871+
),
872+
({"type": "Polygon", "coordinates": [[1, 2]]}, {"Polygon", "MultiPolygon"}, None),
873+
({"type": "MultiPolygon", "coordinates": [[[1, 2]]]}, {"Polygon", "MultiPolygon"}, None),
874+
(
875+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1, 2]]}, "properties": {}},
876+
{"Polygon", "MultiPolygon"},
877+
"Found type 'Feature', but expects one of ['MultiPolygon', 'Polygon']",
878+
),
879+
(
880+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1, 2]]}, "properties": {}},
881+
{"Feature"},
882+
None,
883+
),
884+
(
885+
{
886+
"type": "FeatureCollection",
887+
"features": [
888+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1, 2]]}, "properties": {}},
889+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[3, 4]]}, "properties": {}},
890+
],
891+
},
892+
{"Polygon", "MultiPolygon"},
893+
"Found type 'FeatureCollection', but expects one of ['MultiPolygon', 'Polygon']",
894+
),
895+
(
896+
{
897+
"type": "FeatureCollection",
898+
"features": [
899+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[1, 2]]}, "properties": {}},
900+
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[3, 4]]}, "properties": {}},
901+
],
902+
},
903+
{"FeatureCollection"},
904+
None,
905+
),
906+
],
907+
)
908+
@pytest.mark.parametrize(
909+
"raise_exception",
910+
[
911+
False,
912+
True,
913+
],
914+
)
915+
def test_validate_geojson_basic_allowed_types(self, value, allowed_types, expected_issue, raise_exception):
916+
with self._checker(expected_issue=expected_issue, raise_exception=raise_exception) as check:
917+
result = validate_geojson_basic(value, allowed_types=allowed_types, raise_exception=raise_exception)
918+
check(result)

0 commit comments

Comments
 (0)