Skip to content

Commit

Permalink
[PT-5897] Host search method (#706)
Browse files Browse the repository at this point in the history
* Provider search method

* Test coverage

* Bump version
  • Loading branch information
javidq authored Dec 18, 2024
1 parent bb8d0ec commit 3966a6f
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 8 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ You can check your current version with the following command:
```

For more information, see [UP42 Python package description](https://pypi.org/project/up42-py/).
## 2.2.0a9
**Dec 18, 2024**
- Added `is_host` property to `Provider` class.
- Added `search` method to `Provider` class.
- Deprecated `Catalog::construct_search_parameters` in favour of `Provider::search`.
- Deprecated `Catalog::search` in favour of `Provider::search`.
- Simplified `CatalogBase::estimate_order` to a static method.
- Simplified `CatalogBase::place_order` to a class method.

## 2.2.0a8
**Dec 18, 2024**
- Added `schema` property to `DataProduct` class.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "up42-py"
version = "2.2.0a8"
version = "2.2.0a9"
description = "Python SDK for UP42, the geospatial marketplace and developer platform."
authors = ["UP42 GmbH <support@up42.com>"]
license = "https://github.com/up42/up42-py/blob/master/LICENSE"
Expand Down
112 changes: 110 additions & 2 deletions tests/test_glossary.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import dataclasses
from typing import List, Optional
from typing import Any, List, Optional

import geojson # type: ignore
import pytest
import requests_mock as req_mock

from tests import constants
from tests import constants, helpers
from up42 import glossary, utils

DATA_PRODUCT = glossary.DataProduct(
Expand Down Expand Up @@ -49,6 +50,41 @@
data_products=[DATA_PRODUCT],
metadata=COLLECTION_METADATA,
)
BBOX = [0.0] * 4
POLYGON = {
"type": "Polygon",
"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
}
SCENE = glossary.Scene(
bbox=BBOX,
geometry=POLYGON,
id="scene-id",
datetime="datetime",
start_datetime="start-datetime",
end_datetime="end-datetime",
constellation="constellation",
collection="collection",
cloud_coverage=0.5,
resolution=0.3,
delivery_time="HOURS",
producer="producer",
)
SCENE_FEATURE = {
"geometry": POLYGON,
"bbox": BBOX,
"properties": {
"id": SCENE.id,
"datetime": SCENE.datetime,
"start_datetime": SCENE.start_datetime,
"end_datetime": SCENE.end_datetime,
"constellation": SCENE.constellation,
"collection": SCENE.collection,
"cloudCoverage": SCENE.cloud_coverage,
"resolution": SCENE.resolution,
"deliveryTime": SCENE.delivery_time,
"producer": SCENE.producer,
},
}


class TestDataProduct:
Expand All @@ -70,6 +106,78 @@ def test_should_provide_schema(self, requests_mock: req_mock.Mocker) -> None:
assert self.data_product.schema == schema


class TestProvider:
provider = glossary.Provider(
name="name",
title="title",
description="description",
roles=["PRODUCER", "HOST"],
)

@pytest.mark.parametrize(
"provider, is_host",
[
(provider, True),
(dataclasses.replace(provider, roles=["PRODUCER"]), False),
(dataclasses.replace(provider, roles=["HOST"]), True),
],
)
def test_should_compute_whether_provider_is_host(self, provider, is_host: bool) -> None:
assert provider.is_host == is_host

def test_fails_to_search_if_provider_is_not_host(self):
with pytest.raises(glossary.InvalidHost):
next(dataclasses.replace(self.provider, roles=["PRODUCER"]).search())

@pytest.mark.parametrize("bbox", [None, BBOX])
@pytest.mark.parametrize("intersects", [None, POLYGON])
@pytest.mark.parametrize("datetime", [None, "2030-01-01T00:00:00Z"])
@pytest.mark.parametrize("cql_query", [None, {"cql2": "query"}])
@pytest.mark.parametrize("collections", [None, ["phr", "bjn"]])
def test_should_search(
self,
bbox: Optional[glossary.BoundingBox],
intersects: Optional[geojson.Polygon],
datetime: Optional[str],
cql_query: Optional[dict],
collections: Optional[list[str]],
requests_mock: req_mock.Mocker,
):
search_params: dict[str, Any] = {}
if bbox:
search_params["bbox"] = bbox
if intersects:
search_params["intersects"] = intersects
if datetime:
search_params["datetime"] = datetime
if cql_query:
search_params["query"] = cql_query
if collections:
search_params["collections"] = collections

search_url = f"{constants.API_HOST}/catalog/hosts/{self.provider.name}/stac/search"
next_page_url = f"{search_url}/next"
requests_mock.post(
url=search_url,
json={
"type": "FeatureCollection",
"features": [SCENE_FEATURE] * 3,
"links": [{"rel": "next", "href": next_page_url}],
},
additional_matcher=helpers.match_request_body(search_params),
)
requests_mock.post(
url=next_page_url,
json={
"type": "FeatureCollection",
"features": [SCENE_FEATURE] * 2,
"links": [],
},
additional_matcher=helpers.match_request_body(search_params),
)
assert list(self.provider.search(bbox, intersects, datetime, cql_query, collections)) == [SCENE] * 5


class TestProductGlossary:
@pytest.mark.parametrize(
"collection_type",
Expand Down
11 changes: 7 additions & 4 deletions up42/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,25 @@ def get_data_product_schema(self, data_product_id: str) -> dict:
url = host.endpoint(f"/orders/schema/{data_product_id}")
return self.session.get(url).json()

@staticmethod
@utils.deprecation("BatchOrderTemplate.estimate", "3.0.0")
def estimate_order(self, order_parameters: order.OrderParams) -> int:
def estimate_order(order_parameters: order.OrderParams) -> int:
"""
Estimate the cost of an order.
Args:
order_parameters: A dictionary like
{dataProduct: ..., "params": {"id": ..., "aoi": ...}}
Returns:
int: An estimated cost for the order in UP42 credits.
"""

return order.Order.estimate(order_parameters)

@classmethod
@utils.deprecation("BatchOrderTemplate::place", "3.0.0")
def place_order(
self,
cls,
order_parameters: order.OrderParams,
track_status: bool = False,
report_time: float = 120,
Expand Down Expand Up @@ -106,7 +107,7 @@ def place_order(
Returns:
Order class object of the placed order.
"""
placed_order = order.Order.place(order_parameters, self.workspace_id)
placed_order = order.Order.place(order_parameters, cls.workspace_id)
if track_status:
placed_order.track_status(report_time)
return placed_order
Expand All @@ -130,6 +131,7 @@ def __init__(self):
super().__init__(glossary.CollectionType.ARCHIVE)

@staticmethod
@utils.deprecation("Provider::search", "3.0.0")
def construct_search_parameters(
geometry: Geometry,
collections: List[str],
Expand Down Expand Up @@ -215,6 +217,7 @@ def _get_host(self, collection_names: list[str]) -> str:
raise MultipleHosts("Only collections with the same host can be searched at the same time.")
return hosts.pop()

@utils.deprecation("Provider::search", "3.0.0")
def search(self, search_parameters: dict, as_dataframe: bool = True) -> Union[geopandas.GeoDataFrame, dict]:
"""
Searches the catalog and returns the metadata of the matching scenes.
Expand Down
85 changes: 84 additions & 1 deletion up42/glossary.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import dataclasses
import enum
from typing import Any, Iterator, Literal, Optional
from typing import Any, Iterator, Literal, Optional, Union

import geojson # type: ignore

from up42 import base, host, utils

Expand Down Expand Up @@ -38,13 +40,94 @@ class CollectionMetadata:
resolution_value: Optional[ResolutionValue]


BoundingBox = list[float]


@dataclasses.dataclass
class Scene:
bbox: Optional[BoundingBox]
geometry: Union[geojson.Polygon, geojson.MultiPolygon]
id: str
datetime: Optional[str]
start_datetime: Optional[str]
end_datetime: Optional[str]
constellation: str
collection: str
cloud_coverage: Optional[float]
resolution: Optional[float]
delivery_time: Optional[Literal["MINUTES", "HOURS", "DAYS"]]
producer: str


class InvalidHost(ValueError):
pass


@dataclasses.dataclass
class Provider:
session = base.Session()
name: str
title: str
description: str
roles: list[Literal["PRODUCER", "HOST"]]

@property
def is_host(self):
return "HOST" in self.roles

def search(
self,
bbox: Optional[BoundingBox] = None,
intersects: Optional[geojson.Polygon] = None,
datetime: Optional[str] = None,
query: Optional[dict] = None,
collections: Optional[list[str]] = None,
) -> Iterator[Scene]:
if not self.is_host:
raise InvalidHost("Provider does not host collections")
payload = {
key: value
for key, value in {
"bbox": bbox,
"intersects": intersects,
"datetime": datetime,
"query": query,
"collections": collections,
}.items()
if value
}

def get_pages():
url = host.endpoint(f"/catalog/hosts/{self.name}/stac/search")
while url:
page: dict = self.session.post(url, json=payload).json()
yield page["features"]
url = next(
(link["href"] for link in page["links"] if link["rel"] == "next"),
None,
)

for page in get_pages():
for feature in page:
yield self._as_scene(feature)

def _as_scene(self, feature: geojson.Feature) -> Scene:
properties = feature["properties"]
return Scene(
bbox=feature.get("bbox"),
geometry=feature["geometry"],
id=properties["id"],
constellation=properties["constellation"],
collection=properties["collection"],
producer=properties["producer"],
datetime=properties.get("datetime"),
start_datetime=properties.get("start_datetime"),
end_datetime=properties.get("end_datetime"),
cloud_coverage=properties.get("cloudCoverage"),
resolution=properties.get("resolution"),
delivery_time=properties.get("deliveryTime"),
)


@dataclasses.dataclass
class DataProduct:
Expand Down

0 comments on commit 3966a6f

Please sign in to comment.