diff --git a/docs/api.rst b/docs/api.rst index 91b4e0cbd..bfefe7176 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -70,6 +70,16 @@ openeo.rest.capabilities :members: OpenEoCapabilities +openeo.rest.models +------------------- + +.. automodule:: openeo.rest.models.general + :members: + +.. automodule:: openeo.rest.models.federation_extension + :members: FederationExtension + + openeo.api.process -------------------- diff --git a/openeo/rest/connection.py b/openeo/rest/connection.py index fc5bc9655..876153618 100644 --- a/openeo/rest/connection.py +++ b/openeo/rest/connection.py @@ -75,6 +75,7 @@ from openeo.rest.graph_building import CollectionProperty from openeo.rest.job import BatchJob, RESTJob from openeo.rest.mlmodel import MlModel +from openeo.rest.models.general import CollectionListingResponse from openeo.rest.service import Service from openeo.rest.udp import Parameter, RESTUserDefinedProcess from openeo.rest.userfile import UserFile @@ -898,7 +899,7 @@ def describe_account(self) -> dict: def user_jobs(self) -> List[dict]: return self.list_jobs() - def list_collections(self) -> List[dict]: + def list_collections(self) -> CollectionListingResponse: """ List basic metadata of all collections provided by the back-end. @@ -911,8 +912,8 @@ def list_collections(self) -> List[dict]: :return: list of dictionaries with basic collection metadata. """ # TODO: add caching #383, but reset cache on auth change #254 - data = self.get('/collections', expected_status=200).json()["collections"] - return VisualList("collections", data=data) + data = self.get("/collections", expected_status=200).json() + return CollectionListingResponse(data) def list_collection_ids(self) -> List[str]: """ diff --git a/openeo/rest/models/__init__.py b/openeo/rest/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openeo/rest/models/federation_extension.py b/openeo/rest/models/federation_extension.py new file mode 100644 index 000000000..7e781ee5c --- /dev/null +++ b/openeo/rest/models/federation_extension.py @@ -0,0 +1,24 @@ +from typing import List, Union + + +class FederationExtension: + """ + Wrapper the openEO Federation extension as defined by + https://github.com/Open-EO/openeo-api/tree/draft/extensions/federation + """ + + __slots__ = ["_data"] + + def __init__(self, data: dict): + self._data = data + + @property + def missing(self) -> Union[List[str], None]: + """ + Get the ``federation:missing`` property (if any) of the resource, + which lists back-ends that were not available during the request. + + :return: list of back-end IDs that were not available. + Or None, when ``federation:missing`` is not present in response. + """ + return self._data.get("federation:missing", None) diff --git a/openeo/rest/models/general.py b/openeo/rest/models/general.py new file mode 100644 index 000000000..ef751e365 --- /dev/null +++ b/openeo/rest/models/general.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +from typing import List, Optional, Union + +from openeo.internal.jupyter import render_component +from openeo.rest.models.federation_extension import FederationExtension + + +@dataclass(frozen=True) +class Link: + """ + Container for (web) link data, used throughout the openEO API, + to point to alternate representations, a license, extra detailed information, and more. + """ + + rel: str + href: str + type: Optional[str] = None + title: Optional[str] = None + + @classmethod + def from_dict(cls, data: dict): + return cls(rel=data["rel"], href=data["href"], type=data.get("type"), title=data.get("title")) + + # TODO: add _html_repr_ for Jupyter integration + + +class CollectionListingResponse(list): + """ + Container for collection metadata listing received from a ``GET /collections`` request. + + This object mimics a list of collection metadata dictionaries, + which was the original return API of :py:meth:`~openeo.rest.connection.Connection.list_collections()`, + but now also includes additional metadata like links and extensions. + + :param data: response data from a ``GET /collections`` request + """ + + __slots__ = ["_data"] + + def __init__(self, data: dict): + self._data = data + # Mimic original list of collection metadata dictionaries + super().__init__(data["collections"]) + + def _repr_html_(self): + return render_component(component="collections", data=self) + + @property + def links(self) -> List[Link]: + """Get links from collections response.""" + return [Link.from_dict(d) for d in self._data.get("links", [])] + + @property + def ext_federation(self) -> FederationExtension: + """Accessor for federation extension data.""" + return FederationExtension(self._data) diff --git a/tests/rest/models/__init__.py b/tests/rest/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/rest/models/test_general.py b/tests/rest/models/test_general.py new file mode 100644 index 000000000..aab66c73b --- /dev/null +++ b/tests/rest/models/test_general.py @@ -0,0 +1,62 @@ +import pytest + +from openeo.rest.models.general import CollectionListingResponse, Link + + +class TestLink: + def test_basic(self): + link = Link(rel="about", href="https://example.com/about") + assert link.rel == "about" + assert link.href == "https://example.com/about" + assert link.title is None + assert link.type is None + + def test_full(self): + link = Link(rel="about", href="https://example.com/about", type="text/html", title="About example") + assert link.rel == "about" + assert link.href == "https://example.com/about" + assert link.title == "About example" + assert link.type == "text/html" + + def test_repr(self): + link = Link(rel="about", href="https://example.com/about") + assert repr(link) == "Link(rel='about', href='https://example.com/about', type=None, title=None)" + + +class TestCollectionListingResponse: + def test_basic(self): + data = {"collections": [{"id": "S2"}, {"id": "S3"}]} + collections = CollectionListingResponse(data) + assert collections == [{"id": "S2"}, {"id": "S3"}] + assert repr(collections) == "[{'id': 'S2'}, {'id': 'S3'}]" + + def test_links(self): + data = { + "collections": [{"id": "S2"}, {"id": "S3"}], + "links": [ + {"rel": "self", "href": "https://openeo.test/collections"}, + {"rel": "next", "href": "https://openeo.test/collections?page=2"}, + ], + } + collections = CollectionListingResponse(data) + assert collections.links == [ + Link(rel="self", href="https://openeo.test/collections"), + Link(rel="next", href="https://openeo.test/collections?page=2"), + ] + + @pytest.mark.parametrize( + ["data", "expected"], + [ + ( + {"collections": [{"id": "S2"}], "federation:missing": ["wwu"]}, + ["wwu"], + ), + ( + {"collections": [{"id": "S2"}]}, + None, + ), + ], + ) + def test_federation_missing(self, data, expected): + collections = CollectionListingResponse(data) + assert collections.ext_federation.missing == expected diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index ed60e9337..c224fbac3 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -40,6 +40,7 @@ extract_connections, paginate, ) +from openeo.rest.models.general import Link from openeo.rest.vectorcube import VectorCube from openeo.testing.stac import StacDummyBuilder from openeo.util import ContextTimer, deep_get, dict_no_none @@ -3329,6 +3330,23 @@ def test_list_collections(requests_mock): assert con.list_collections() == collections +def test_list_collections_extra_metadata(requests_mock): + requests_mock.get(API_URL, json={"api_version": "1.0.0"}) + requests_mock.get( + API_URL + "collections", + json={ + "collections": [{"id": "S2"}, {"id": "NDVI"}], + "links": [{"rel": "next", "href": "https://oeo.test/collections?page=2"}], + "federation:missing": ["oeob"], + }, + ) + con = Connection(API_URL) + collections = con.list_collections() + assert collections == [{"id": "S2"}, {"id": "NDVI"}] + assert collections.links == [Link(rel="next", href="https://oeo.test/collections?page=2", type=None, title=None)] + assert collections.ext_federation.missing == ["oeob"] + + def test_describe_collection(requests_mock): requests_mock.get(API_URL, json={"api_version": "1.0.0"}) requests_mock.get(