Skip to content

Commit f95db34

Browse files
authored
Merge pull request #4 from geopython/odata-implementation
odata implementation
2 parents d5faa12 + 6648ee8 commit f95db34

5 files changed

Lines changed: 1546 additions & 1 deletion

File tree

src/eodm/extract.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
from datetime import datetime
12
from typing import Iterator, Optional
23

34
import pystac_client
5+
from geojson_pydantic.geometries import Geometry
46
from owslib.ogcapi.records import Records
57
from pystac import Collection, Item
68

9+
from .odata import ODataClient, ODataCollection, ODataProduct, ODataQuery
710
from .opensearch import OpenSearchClient, OpenSearchFeature
811

912

@@ -66,7 +69,7 @@ def extract_opensearch_features(
6669
6770
Args:
6871
url (str): Link to OpenSearch API endpoint
69-
productTypes (list[str]): List of productTypes to search for
72+
product_types (list[str]): List of productTypes to search for
7073
7174
Yields:
7275
Iterator[OpenSearchFeature]: OpenSearch Features
@@ -84,6 +87,42 @@ def extract_opensearch_features(
8487
yield feature
8588

8689

90+
def extract_odata_products(
91+
url: str,
92+
collections: list[ODataCollection],
93+
datetime: tuple[datetime, datetime] | None = None,
94+
intersect_geometry: Geometry | None = None,
95+
online: bool = True,
96+
cloud_cover_less_than: int | None = None,
97+
name_contains: Optional[str] = None,
98+
name_not_contains: Optional[str] = None,
99+
top: int = 20,
100+
) -> Iterator[ODataProduct]:
101+
"""Extracts OData Products from an OData API
102+
103+
Args:
104+
url (str): Link to OData API endpoint
105+
collections (list[ODataCollection]): List of collections to search for
106+
datetime (tuple[datetime, datetime], optional): Datetime interval to search. Defaults to None.
107+
intersect_geometry (Geometry, optional): Geometry to intersect. Defaults to None.
108+
online (bool, optional): Filter for online products. Defaults to True.
109+
"""
110+
client = ODataClient(url)
111+
for collection in collections:
112+
query = ODataQuery(
113+
collection=collection.value,
114+
top=top,
115+
sensing_date=datetime,
116+
cloud_cover_less_than=cloud_cover_less_than,
117+
intersect_geometry=intersect_geometry,
118+
online=online,
119+
name_contains=name_contains,
120+
name_not_contains=name_not_contains,
121+
)
122+
for product in client.search(query):
123+
yield product
124+
125+
87126
def extract_ogcapi_records_catalogs(url: str) -> Iterator[dict]:
88127
"""Extracts OGC API Records from an OGC API Records endpoint
89128

src/eodm/odata.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from datetime import datetime
2+
from enum import Enum
3+
from typing import Annotated, Iterator
4+
5+
import httpx
6+
from geojson_pydantic.geometries import Geometry
7+
from pydantic import BaseModel, Field
8+
9+
10+
class ODataCollection(Enum):
11+
SENTINEL_1 = "SENTINEL-1"
12+
SENTINEL_2 = "SENTINEL-2"
13+
SENTINEL_3 = "SENTINEL-3"
14+
SENTINEL_5P = "SENTINEL-5P"
15+
SENTINEL_6 = "SENTINEL-6"
16+
SENTINEL_1_RTC = "SENTINEL-1-RTC"
17+
GLOBAL_MOSAICS = "GLOBAL-MOSAICS"
18+
SMOS = "SMOS"
19+
ENVISAT = "ENVISAT"
20+
LANDSAT_5 = "LANDSAT-5"
21+
LANDSAT_7 = "LANDSAT-7"
22+
LANDSAT_8 = "LANDSAT-8"
23+
COP_DEM = "COP-DEM"
24+
TERRAAQUA = "TERRAAQUA"
25+
S2GLC = "S2GLC"
26+
CCM = "CCM"
27+
28+
29+
class ODataChecksum(BaseModel):
30+
value: Annotated[str, Field(alias="Value")]
31+
algorithm: Annotated[str, Field(alias="Algorithm")]
32+
checksum_date: Annotated[str, Field(alias="ChecksumDate")]
33+
34+
35+
class ODataContentDate(BaseModel):
36+
start: Annotated[str, Field(alias="Start")]
37+
end: Annotated[str, Field(alias="End")]
38+
39+
40+
class ODataProduct(BaseModel):
41+
media_content_type: Annotated[str, Field(alias="@odata.mediaContentType")]
42+
id: Annotated[str, Field(alias="Id")]
43+
name: Annotated[str, Field(alias="Name")]
44+
content_type: Annotated[str, Field(alias="ContentType")]
45+
content_length: Annotated[int, Field(alias="ContentLength")]
46+
origin_date: Annotated[str, Field(alias="OriginDate")]
47+
publication_date: Annotated[str, Field(alias="PublicationDate")]
48+
modification_date: Annotated[str, Field(alias="ModificationDate")]
49+
online: Annotated[bool, Field(alias="Online")]
50+
eviction_date: Annotated[str, Field(alias="EvictionDate")]
51+
s3_path: Annotated[str, Field(alias="S3Path")]
52+
checksum: Annotated[list[ODataChecksum], Field(alias="Checksum")]
53+
footprint: Annotated[Geometry | str | None, Field(alias="Footprint")]
54+
geo_footprint: Annotated[Geometry | None, Field(alias="GeoFootprint")]
55+
56+
57+
class ODataResult(BaseModel):
58+
odata_context: Annotated[str, Field(alias="@odata.context")]
59+
value: list[ODataProduct]
60+
odata_nextlink: Annotated[str | None, Field(alias="@odata.nextLink")] = None
61+
62+
63+
class ODataQuery:
64+
def __init__(
65+
self,
66+
collection: str | None = None,
67+
name: str | None = None,
68+
top: int = 20,
69+
publication_date: tuple[datetime, datetime] | None = None,
70+
sensing_date: tuple[datetime, datetime] | None = None,
71+
intersect_geometry: Geometry | None = None,
72+
online: bool = True,
73+
cloud_cover_less_than: int | None = None,
74+
name_contains: str | None = None,
75+
name_not_contains: str | None = None,
76+
):
77+
self.collection = collection
78+
self.name = name
79+
self.top = top
80+
self.publication_date = publication_date
81+
self.sensing_date = sensing_date
82+
self.intersect_geometry = intersect_geometry
83+
self.online = online
84+
self.cloud_cover_less_than = cloud_cover_less_than
85+
self.name_contains = name_contains
86+
self.name_not_contains = name_not_contains
87+
88+
def to_params(self) -> dict:
89+
query = []
90+
if self.collection:
91+
query.append(f"Collection/Name eq '{self.collection}'")
92+
if self.name:
93+
query.append("Name eq '{self.name}'")
94+
if self.publication_date:
95+
query.append(
96+
f"PublicationDate ge {self.publication_date[0].isoformat()} and PublicationDate le {self.publication_date[1].isoformat()}"
97+
)
98+
if self.sensing_date:
99+
query.append(
100+
f"ContentDate/Start ge {self.sensing_date[0].isoformat()} and ContentDate/Start le {self.sensing_date[1].isoformat()}"
101+
)
102+
if self.intersect_geometry:
103+
query.append(
104+
f"intersects(area=geography'SRID=4326;{self.intersect_geometry.wkt}')"
105+
)
106+
if self.online:
107+
query.append("Online eq true")
108+
if self.cloud_cover_less_than:
109+
query.append(
110+
f"Attributes/OData.CSC.DoubleAttribute/any(att:att/Name eq 'cloudCover' and att/OData.CSC.DoubleAttribute/Value le {self.cloud_cover_less_than})"
111+
)
112+
if self.name_contains:
113+
query.append(f"contains(Name, '{self.name_contains}')")
114+
if self.name_not_contains:
115+
query.append(f"not contains(Name, '{self.name_not_contains}')")
116+
117+
return {
118+
"$filter": " and ".join(query),
119+
"$top": self.top,
120+
}
121+
122+
123+
class ODataClient:
124+
def __init__(self, url: str):
125+
self.url = url
126+
127+
def search(self, query: ODataQuery) -> Iterator[ODataProduct]:
128+
response = httpx.get(self.url, params=query.to_params())
129+
130+
product_collection = ODataResult.model_validate(response.json())
131+
yield from product_collection.value

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,19 @@ def mock_opensearch_search(respx_mock: respx.MockRouter, opensearch_product_type
9090
).mock(return_value=Response(200, content=data))
9191

9292
return mock
93+
94+
95+
@pytest.fixture()
96+
def mock_odata_search(
97+
respx_mock: respx.MockRouter,
98+
):
99+
odata_data = DATA_DIR / "odata.json"
100+
101+
with open(odata_data) as f:
102+
data = f.read()
103+
104+
mock = respx_mock.get(
105+
"https://catalogue.dataspace.copernicus.eu/odata/v1/Products"
106+
).mock(return_value=Response(200, content=data))
107+
108+
return mock

0 commit comments

Comments
 (0)