Skip to content

Commit 357d3cc

Browse files
committed
Issue #424 added initial support for load_geojson
1 parent cfdb3e3 commit 357d3cc

File tree

9 files changed

+306
-112
lines changed

9 files changed

+306
-112
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Initial `load_geojson` support with `Connection.load_geojson()` ([#424](https://github.com/Open-EO/openeo-python-client/issues/424))
13+
1214
### Changed
1315

1416
- `Connection` based requests: always use finite timeouts by default (20 minutes in general, 30 minutes for synchronous execute requests)

openeo/api/process.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ def raster_cube(cls, name: str = "data", description: str = "A data cube.") -> '
4545
"""
4646
return cls(name=name, description=description, schema={"type": "object", "subtype": "raster-cube"})
4747

48+
@classmethod
49+
def datacube(cls, name: str = "data", description: str = "A data cube.") -> "Parameter":
50+
"""
51+
Helper to easily create a 'datacube' parameter.
52+
53+
:param name: name of the parameter.
54+
:param description: description of the parameter
55+
:return: Parameter
56+
57+
.. versionadded:: 0.22.0
58+
"""
59+
return cls(name=name, description=description, schema={"type": "object", "subtype": "datacube"})
60+
4861
@classmethod
4962
def string(cls, name: str, description: str = None, default=_DEFAULT_UNDEFINED, values=None) -> 'Parameter':
5063
"""Helper to create a 'string' type parameter."""

openeo/rest/_testing.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import re
2+
3+
from openeo import Connection
4+
5+
6+
class DummyBackend:
7+
"""
8+
Dummy backend that handles sync/batch execution requests
9+
and allows inspection of posted process graphs
10+
"""
11+
12+
# Default result (can serve both as JSON or binary data)
13+
DEFAULT_RESULT = b'{"what?": "Result data"}'
14+
15+
def __init__(self, requests_mock, connection: Connection):
16+
self.connection = connection
17+
self.sync_requests = []
18+
self.batch_jobs = {}
19+
self.next_result = self.DEFAULT_RESULT
20+
requests_mock.post(connection.build_url("/result"), content=self._handle_post_result)
21+
requests_mock.post(connection.build_url("/jobs"), content=self._handle_post_jobs)
22+
requests_mock.post(
23+
re.compile(connection.build_url(r"/jobs/(job-\d+)/results$")), content=self._handle_post_job_results
24+
)
25+
requests_mock.get(re.compile(connection.build_url(r"/jobs/(job-\d+)$")), json=self._handle_get_job)
26+
requests_mock.get(
27+
re.compile(connection.build_url(r"/jobs/(job-\d+)/results$")), json=self._handle_get_job_results
28+
)
29+
requests_mock.get(
30+
re.compile(connection.build_url("/jobs/(.*?)/results/result.data$")),
31+
content=self._handle_get_job_result_asset,
32+
)
33+
34+
def _handle_post_result(self, request, context):
35+
"""handler of `POST /result` (synchronous execute)"""
36+
pg = request.json()["process"]["process_graph"]
37+
self.sync_requests.append(pg)
38+
return self.next_result
39+
40+
def _handle_post_jobs(self, request, context):
41+
"""handler of `POST /jobs` (create batch job)"""
42+
pg = request.json()["process"]["process_graph"]
43+
job_id = f"job-{len(self.batch_jobs):03d}"
44+
self.batch_jobs[job_id] = {"job_id": job_id, "pg": pg, "status": "created"}
45+
context.status_code = 201
46+
context.headers["openeo-identifier"] = job_id
47+
48+
def _get_job_id(self, request) -> str:
49+
match = re.match(r"^/jobs/(job-\d+)(/|$)", request.path)
50+
if not match:
51+
raise ValueError(f"Failed to extract job_id from {request.path}")
52+
job_id = match.group(1)
53+
assert job_id in self.batch_jobs
54+
return job_id
55+
56+
def _handle_post_job_results(self, request, context):
57+
"""Handler of `POST /job/{job_id}/results` (start batch job)."""
58+
job_id = self._get_job_id(request)
59+
assert self.batch_jobs[job_id]["status"] == "created"
60+
# TODO: support custom status sequence (instead of directly going to status "finished")?
61+
self.batch_jobs[job_id]["status"] = "finished"
62+
context.status_code = 202
63+
64+
def _handle_get_job(self, request, context):
65+
"""Handler of `GET /job/{job_id}` (get batch job status and metadata)."""
66+
job_id = self._get_job_id(request)
67+
return {"id": job_id, "status": self.batch_jobs[job_id]["status"]}
68+
69+
def _handle_get_job_results(self, request, context):
70+
"""Handler of `GET /job/{job_id}/results` (list batch job results)."""
71+
job_id = self._get_job_id(request)
72+
assert self.batch_jobs[job_id]["status"] == "finished"
73+
return {
74+
"id": job_id,
75+
"assets": {"result.data": {"href": self.connection.build_url(f"/jobs/{job_id}/results/result.data")}},
76+
}
77+
78+
def _handle_get_job_result_asset(self, request, context):
79+
"""Handler of `GET /job/{job_id}/results/result.data` (get batch job result asset)."""
80+
job_id = self._get_job_id(request)
81+
assert self.batch_jobs[job_id]["status"] == "finished"
82+
return self.next_result
83+
84+
def get_sync_pg(self) -> dict:
85+
"""Get one and only synchronous process graph"""
86+
assert len(self.sync_requests) == 1
87+
return self.sync_requests[0]
88+
89+
def get_batch_pg(self) -> dict:
90+
"""Get one and only batch process graph"""
91+
assert len(self.batch_jobs) == 1
92+
return self.batch_jobs[max(self.batch_jobs.keys())]["pg"]
93+
94+
def get_pg(self) -> dict:
95+
"""Get one and only batch process graph (sync or batch)"""
96+
pgs = self.sync_requests + [b["pg"] for b in self.batch_jobs.values()]
97+
assert len(pgs) == 1
98+
return pgs[0]

openeo/rest/connection.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
import requests
1616
from requests import Response
1717
from requests.auth import HTTPBasicAuth, AuthBase
18+
import shapely.geometry.base
1819

1920
import openeo
2021
from openeo.capabilities import ApiVersionException, ComparableVersion
2122
from openeo.config import get_config_option, config_log
23+
from openeo.internal.documentation import openeo_process
2224
from openeo.internal.graph_building import PGNode, as_flat_graph, FlatGraphableMixin
2325
from openeo.internal.jupyter import VisualDict, VisualList
2426
from openeo.internal.processes.builder import ProcessBuilderBase
@@ -1095,12 +1097,13 @@ def datacube_from_json(self, src: Union[str, Path], parameters: Optional[dict] =
10951097
"""
10961098
return self.datacube_from_flat_graph(load_json_resource(src), parameters=parameters)
10971099

1100+
@openeo_process
10981101
def load_collection(
10991102
self,
11001103
collection_id: str,
11011104
spatial_extent: Optional[Dict[str, float]] = None,
1102-
temporal_extent: Optional[List[Union[str, datetime.datetime, datetime.date]]] = None,
1103-
bands: Optional[List[str]] = None,
1105+
temporal_extent: Optional[List[Union[str, datetime.datetime, datetime.date]]] = None,
1106+
bands: Optional[List[str]] = None,
11041107
properties: Optional[Dict[str, Union[str, PGNode, Callable]]] = None,
11051108
max_cloud_cover: Optional[float] = None,
11061109
fetch_metadata=True,
@@ -1131,6 +1134,7 @@ def load_collection(
11311134
load_collection, name="imagecollection", since="0.4.10"
11321135
)
11331136

1137+
@openeo_process
11341138
def load_result(
11351139
self,
11361140
id: str,
@@ -1168,6 +1172,7 @@ def load_result(
11681172
cube.metadata = metadata
11691173
return cube
11701174

1175+
@openeo_process
11711176
def load_stac(
11721177
self,
11731178
url: str,
@@ -1305,6 +1310,33 @@ def load_ml_model(self, id: Union[str, BatchJob]) -> "MlModel":
13051310
"""
13061311
return MlModel.load_ml_model(connection=self, id=id)
13071312

1313+
@openeo_process
1314+
def load_geojson(
1315+
self,
1316+
data: Union[dict, str, Path, shapely.geometry.base.BaseGeometry, Parameter],
1317+
properties: Optional[List[str]] = None,
1318+
):
1319+
"""
1320+
Converts GeoJSON data as defined by RFC 7946 into a vector data cube.
1321+
1322+
:param connection: the connection to use to connect with the openEO back-end.
1323+
:param data: the geometry to load. One of:
1324+
1325+
- GeoJSON-style data structure: e.g. a dictionary with ``"type": "Polygon"`` and ``"coordinates"`` fields
1326+
- a path to a local GeoJSON file
1327+
- a GeoJSON string
1328+
- a shapely geometry object
1329+
1330+
:param properties: A list of properties from the GeoJSON file to construct an additional dimension from.
1331+
:return: new VectorCube instance
1332+
1333+
.. warning:: EXPERIMENTAL: this process is experimental with the potential for major things to change.
1334+
1335+
.. versionadded:: 0.22.0
1336+
"""
1337+
1338+
return VectorCube.load_geojson(connection=self, data=data, properties=properties)
1339+
13081340
def create_service(self, graph: dict, type: str, **kwargs) -> Service:
13091341
# TODO: type hint for graph: is it a nested or a flat one?
13101342
req = self._build_request_with_process_graph(process_graph=graph, type=type, **kwargs)

openeo/rest/vectorcube.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import json
12
import pathlib
23
import typing
3-
from typing import Union, Optional
4+
from typing import List, Optional, Union
45

6+
import shapely.geometry.base
7+
8+
from openeo.api.process import Parameter
59
from openeo.internal.documentation import openeo_process
610
from openeo.internal.graph_building import PGNode
711
from openeo.internal.warnings import legacy_alias
812
from openeo.metadata import CollectionMetadata
9-
from openeo.rest._datacube import _ProcessGraphAbstraction, UDF
10-
from openeo.rest.mlmodel import MlModel
13+
from openeo.rest._datacube import UDF, _ProcessGraphAbstraction
1114
from openeo.rest.job import BatchJob
15+
from openeo.rest.mlmodel import MlModel
1216
from openeo.util import dict_no_none, guess_format
1317

1418
if typing.TYPE_CHECKING:
@@ -42,11 +46,58 @@ def process(
4246
4347
:param process_id: process id of the process.
4448
:param args: argument dictionary for the process.
45-
:return: new DataCube instance
49+
:return: new VectorCube instance
4650
"""
4751
pg = self._build_pgnode(process_id=process_id, arguments=arguments, namespace=namespace, **kwargs)
4852
return VectorCube(graph=pg, connection=self._connection, metadata=metadata or self.metadata)
4953

54+
@classmethod
55+
@openeo_process
56+
def load_geojson(
57+
cls,
58+
connection: "openeo.Connection",
59+
data: Union[dict, str, pathlib.Path, shapely.geometry.base.BaseGeometry, Parameter],
60+
properties: Optional[List[str]] = None,
61+
):
62+
"""
63+
Converts GeoJSON data as defined by RFC 7946 into a vector data cube.
64+
65+
:param connection: the connection to use to connect with the openEO back-end.
66+
:param data: the geometry to load. One of:
67+
68+
- GeoJSON-style data structure: e.g. a dictionary with ``"type": "Polygon"`` and ``"coordinates"`` fields
69+
- a path to a local GeoJSON file
70+
- a GeoJSON string
71+
- a shapely geometry object
72+
73+
:param properties: A list of properties from the GeoJSON file to construct an additional dimension from.
74+
:return: new VectorCube instance
75+
76+
.. warning:: EXPERIMENTAL: this process is experimental with the potential for major things to change.
77+
78+
.. versionadded:: 0.22.0
79+
"""
80+
# TODO: unify with `DataCube._get_geometry_argument`
81+
if isinstance(data, str) and data.strip().startswith("{"):
82+
# Assume JSON dump
83+
geometry = json.loads(data)
84+
elif isinstance(data, (str, pathlib.Path)):
85+
# Assume local file
86+
with pathlib.Path(data).open(mode="r", encoding="utf-8") as f:
87+
geometry = json.load(f)
88+
assert isinstance(geometry, dict)
89+
elif isinstance(data, shapely.geometry.base.BaseGeometry):
90+
geometry = shapely.geometry.mapping(data)
91+
elif isinstance(data, Parameter):
92+
geometry = data
93+
elif isinstance(data, dict):
94+
geometry = data
95+
else:
96+
raise ValueError(data)
97+
98+
pg = PGNode(process_id="load_geojson", data=geometry, properties=properties or [])
99+
return cls(graph=pg, connection=connection)
100+
50101
@openeo_process
51102
def run_udf(
52103
self,

openeo/udf/udf_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def __init__(
3232
The constructor of the UDF argument class that stores all data required by the
3333
user defined function.
3434
35-
:param proj: A dictionary of form {"proj type string": "projection description"} i. e. {"EPSG":4326}
35+
:param proj: A dictionary of form {"proj type string": "projection description"} e.g. {"EPSG": 4326}
3636
:param datacube_list: A list of data cube objects
3737
:param feature_collection_list: A list of VectorTile objects
3838
:param structured_data_list: A list of structured data objects

tests/rest/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
import time_machine
1010

11+
from openeo.rest._testing import DummyBackend
1112
from openeo.rest.connection import Connection
1213

1314
API_URL = "https://oeo.test/"
@@ -71,8 +72,20 @@ def assert_oidc_device_code_flow(url: str = "https://oidc.test/dc", elapsed: flo
7172
return assert_oidc_device_code_flow
7273

7374

75+
@pytest.fixture
76+
def con100(requests_mock):
77+
requests_mock.get(API_URL, json={"api_version": "1.0.0"})
78+
con = Connection(API_URL)
79+
return con
80+
81+
7482
@pytest.fixture
7583
def con120(requests_mock):
7684
requests_mock.get(API_URL, json={"api_version": "1.2.0"})
7785
con = Connection(API_URL)
7886
return con
87+
88+
89+
@pytest.fixture
90+
def dummy_backend(requests_mock, con100) -> DummyBackend:
91+
yield DummyBackend(requests_mock=requests_mock, connection=con100)

0 commit comments

Comments
 (0)