From 359b12aecbd88297283bad6674c37abf8b4e8248 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Wed, 12 Apr 2023 15:24:35 -0400 Subject: [PATCH 1/9] Add simple version of `get_queryables` --- pystac_client/_utils.py | 2 +- pystac_client/client.py | 29 +- pystac_client/collection_client.py | 31 ++- .../TestQueryables.test_get_queryables.yaml | 130 +++++++++ ...tCollectionClient.test_get_queryables.yaml | 257 ++++++++++++++++++ tests/test_client.py | 23 ++ tests/test_collection_client.py | 10 + 7 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 tests/cassettes/test_client/TestQueryables.test_get_queryables.yaml create mode 100644 tests/cassettes/test_collection_client/TestCollectionClient.test_get_queryables.yaml diff --git a/pystac_client/_utils.py b/pystac_client/_utils.py index f2dccb35..cda15a9b 100644 --- a/pystac_client/_utils.py +++ b/pystac_client/_utils.py @@ -1,10 +1,10 @@ import warnings from typing import Callable, Optional, Union - import pystac from pystac_client.errors import IgnoredResultWarning + Modifiable = Union[pystac.Collection, pystac.Item, pystac.ItemCollection, dict] diff --git a/pystac_client/client.py b/pystac_client/client.py index 4b80882a..205fb2ec 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -236,7 +236,9 @@ def from_dict( return result @lru_cache() - def get_collection(self, collection_id: str) -> Optional[Collection]: + def get_collection( + self, collection_id: str + ) -> Optional[Union[Collection, CollectionClient]]: """Get a single collection from this Catalog/API Args: @@ -262,7 +264,7 @@ def get_collection(self, collection_id: str) -> Optional[Collection]: return None - def get_collections(self) -> Iterator[Collection]: + def get_collections(self) -> Iterator[Union[Collection, CollectionClient]]: """Get Collections in this Catalog Gets the collections from the /collections endpoint if supported, @@ -497,6 +499,29 @@ def get_search_link(self) -> Optional[pystac.Link]: None, ) + def get_queryables(self) -> Dict[str, Any]: + """Return all queryables. + + Output is a dictionary that can be used in ``jsonshema.validate`` + + Return: + Dict[str, Any]: Dictionary containing queryable fields + """ + assert self._stac_io is not None + self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) + + self_href = self.get_self_href() + if self_href is None: + raise ValueError("cannot build a queryable href without a self href") + + url = f"{self_href.rstrip('/')}/queryables" + + result = self._stac_io.read_json(url) + if "properties" not in result: + raise APIError("Invalid response from /queryables") + + return result + def _get_collections_href(self, collection_id: Optional[str] = None) -> str: self_href = self.get_self_href() if self_href is None: diff --git a/pystac_client/collection_client.py b/pystac_client/collection_client.py index 30fd1fba..b92e6375 100644 --- a/pystac_client/collection_client.py +++ b/pystac_client/collection_client.py @@ -14,7 +14,6 @@ class CollectionClient(pystac.Collection): modifier: Callable[[Modifiable], None] - _stac_io: Optional[StacApiIO] def __init__( self, @@ -159,7 +158,7 @@ def get_item(self, id: str, recursive: bool = False) -> Optional["Item_Type"]: item_search = ItemSearch( url=search_link.href, method="GET", - stac_io=self._stac_io, + stac_io=stac_io, ids=[id], collections=[self.id], modifier=self.modifier, @@ -174,3 +173,31 @@ def get_item(self, id: str, recursive: bool = False) -> Optional["Item_Type"]: call_modifier(self.modifier, item) return item + + def get_queryables(self) -> Dict[str, Any]: + """Return all queryables. + + Output is a dictionary that can be used in ``jsonshema.validate`` + + Return: + Dict[str, Any]: Dictionary containing queryable fields + """ + root = self.get_root() + assert root + stac_io = root._stac_io + assert stac_io + assert isinstance(stac_io, StacApiIO) + + stac_io.assert_conforms_to(ConformanceClasses.FILTER) + + self_href = self.get_self_href() + if self_href is None: + raise ValueError("cannot build a queryable href without a self href") + + url = f"{self_href.rstrip('/')}/queryables" + + result = stac_io.read_json(url) + if "properties" not in result: + raise APIError("Invalid response from /queryables") + + return result diff --git a/tests/cassettes/test_client/TestQueryables.test_get_queryables.yaml b/tests/cassettes/test_client/TestQueryables.test_get_queryables.yaml new file mode 100644 index 00000000..7fda8eed --- /dev/null +++ b/tests/cassettes/test_client/TestQueryables.test_get_queryables.yaml @@ -0,0 +1,130 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.28.2 + method: GET + uri: https://planetarycomputer.microsoft.com/api/stac/v1 + response: + body: + string: !!binary | + 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 + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Origin: + - '*' + Content-Encoding: + - gzip + Content-Length: + - '2906' + Content-Type: + - application/json + Date: + - Wed, 12 Apr 2023 18:24:59 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Accept-Encoding + X-Azure-Ref: + - 0fPc2ZAAAAADutojn4rB+S6IokbBhkP27RVdSMzBFREdFMDUxMAA5MjdhYmZhNi0xOWY2LTRhZjEtYTA5ZC1jOTU5ZDlhMWU2NDQ= + X-Cache: + - CONFIG_NOCACHE + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.28.2 + method: GET + uri: https://planetarycomputer.microsoft.com/api/stac/v1/queryables + response: + body: + string: '{"$schema":"http://json-schema.org/draft-07/schema#","$id":"https://example.org/queryables","type":"object","title":"","properties":{"id":{"title":"Item + ID","description":"Item identifier","$ref":"https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/definitions/core/allOf/2/properties/id"}}}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Origin: + - '*' + Content-Length: + - '308' + Content-Type: + - application/schema+json + Date: + - Wed, 12 Apr 2023 18:25:04 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Azure-Ref: + - 0fPc2ZAAAAAD8HYLLizYsQorHSY/+cLX5RVdSMzBFREdFMDUxMAA5MjdhYmZhNi0xOWY2LTRhZjEtYTA5ZC1jOTU5ZDlhMWU2NDQ= + X-Cache: + - CONFIG_NOCACHE + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_collection_client/TestCollectionClient.test_get_queryables.yaml b/tests/cassettes/test_collection_client/TestCollectionClient.test_get_queryables.yaml new file mode 100644 index 00000000..31ddefea --- /dev/null +++ b/tests/cassettes/test_collection_client/TestCollectionClient.test_get_queryables.yaml @@ -0,0 +1,257 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.28.2 + method: GET + uri: https://planetarycomputer.microsoft.com/api/stac/v1 + response: + body: + string: !!binary | + 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 + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Origin: + - '*' + Connection: + - keep-alive + Content-Length: + - '2906' + Content-Type: + - application/json + Date: + - Wed, 12 Apr 2023 17:37:42 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Cache: + - CONFIG_NOCACHE + content-encoding: + - gzip + vary: + - Accept-Encoding + x-azure-ref: + - 20230412T173741Z-v24x6c19zp4bm476u232xg3sfn000000009g000000015382 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.28.2 + method: GET + uri: https://planetarycomputer.microsoft.com/api/stac/v1/collections/landsat-c2-l2 + response: + body: + string: !!binary | + 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 + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Origin: + - '*' + Connection: + - keep-alive + Content-Length: + - '3454' + Content-Type: + - application/json + Date: + - Wed, 12 Apr 2023 17:37:42 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Cache: + - CONFIG_NOCACHE + content-encoding: + - gzip + vary: + - Accept-Encoding + x-azure-ref: + - 20230412T173742Z-v24x6c19zp4bm476u232xg3sfn000000009g00000001539g + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.28.2 + method: GET + uri: https://planetarycomputer.microsoft.com/api/stac/v1/collections/landsat-c2-l2/queryables + response: + body: + string: !!binary | + H4sIAGbsNmQC/71U227TQBD9FWvbB1DrSyJQwW9R+hKpEqGthARF1sYeJ1vtxd0dpw2l/86sncsm + ECR46JPHZ2fOOR7vzDM7deUCFGc5WyA2eZreO6PjHkyMnaeV5TXG2UXaYyfsnJ2Kap3vqACeuGok + dMkPLdgVn0lwlIarBijPzO6hRP8uUHrg8zYpqo2NppJrQG5X0diopkWw0RXXleMYjYfR1TC6uR2N + o9F0QhSNNQ1YFMSfP7OKI6BQ0MXgSisaFEaTxOXmZOvCoRV6HrgYlQ+tsFARRC4UR8I8YbyuaziS + FU/25u7uLMvyLPv59e0pezln/vOft0QTBBVNLqlk30OHiwo0ilqA9X2zUAeN6xvqEoe8dA2UXQeX + gyRLslRQcezB8H90aOKBk7SCWmjhpVxaGgspl/JTnQ7TXYtS8kluweSlNG1VlGZJNsj5oQ/Sj+EJ + QTtPl8wFLtpZIkwKZuNnfSF+16ZPk5ULVQ/0yMFSwGNu6rrQvBL/5sCX/oeHA8WNB9fqgv8QqsXF + q7kINUMfIGHJ+6vyik52quRF9mOWP1pXWPMYXuov1zfRNUGH83NQRUOyOCybeuwvda4EDcX+DN14 + rB+io3XBpSo8FtaP/Rntj+V6eex4dKtm3fApao9qFcszivlTHw+yLFQwUtKqou4UJW2CubGrPZHt + cTTmmPxht4D2pN/Y7YBebofsO5E3kqNfMCHTdIMdp1h7it+xrb/4fRBfBPGHIP7YiQpNhK2i3bO3 + qXbocWH0rgDVGT2MFN3GtI5YX15+Acl55WAuBgAA + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Origin: + - '*' + Connection: + - keep-alive + Content-Length: + - '600' + Content-Type: + - application/schema+json + Date: + - Wed, 12 Apr 2023 17:37:42 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Cache: + - CONFIG_NOCACHE + content-encoding: + - gzip + vary: + - Accept-Encoding + x-azure-ref: + - 20230412T173742Z-v24x6c19zp4bm476u232xg3sfn000000009g0000000153ab + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_client.py b/tests/test_client.py index 232e473d..b9225467 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -607,3 +607,26 @@ def modifier_bad(x: Any) -> Any: client = Client.open(STAC_URLS["PLANETARY-COMPUTER"], modifier=modifier_bad) with pytest.warns(IgnoredResultWarning): client.get_collection("sentinel-2-l2a") + + +class TestQueryables: + @pytest.mark.vcr + def test_get_queryables(self) -> None: + api = Client.open(STAC_URLS["PLANETARY-COMPUTER"]) + result = api.get_queryables() + assert "properties" in result + assert "id" in result["properties"] + + def test_get_queryables_fails_if_no_self_link(self, requests_mock: Mocker) -> None: + pc_root_text = read_data_file("planetary-computer-root.json") + root_url = "http://pystac-client.test/" + requests_mock.get(root_url, status_code=200, text=pc_root_text) + api = Client.open(root_url) + with pytest.raises(NotImplementedError, match="FILTER not supported"): + api.get_queryables() + + assert api._stac_io is not None + api._stac_io._conformance = None + api.set_self_href(None) + with pytest.raises(ValueError, match="queryable href without a self href"): + api.get_queryables() diff --git a/tests/test_collection_client.py b/tests/test_collection_client.py index 552f327c..a8ab2ea7 100644 --- a/tests/test_collection_client.py +++ b/tests/test_collection_client.py @@ -60,3 +60,13 @@ def test_get_item_with_item_search(self) -> None: ) assert item assert item.id == "AST_L1T_00312272006020322_20150518201805" + + @pytest.mark.vcr + def test_get_queryables(self) -> None: + api = Client.open(STAC_URLS["PLANETARY-COMPUTER"]) + collection_client = api.get_collection("landsat-c2-l2") + assert collection_client is not None + assert isinstance(collection_client, CollectionClient) + result = collection_client.get_queryables() + assert "instrument" in result["properties"] + assert "landsat:scene_id" in result["properties"] From d3c4b6334b31f16b8e9265bcbdac69c7973eb9ca Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Thu, 13 Apr 2023 10:33:20 -0400 Subject: [PATCH 2/9] Use Mixin --- pystac_client/_utils.py | 39 ++++++++++++++++++++++++++- pystac_client/client.py | 27 ++----------------- pystac_client/collection_client.py | 42 +++++++++--------------------- 3 files changed, 52 insertions(+), 56 deletions(-) diff --git a/pystac_client/_utils.py b/pystac_client/_utils.py index cda15a9b..d9b68778 100644 --- a/pystac_client/_utils.py +++ b/pystac_client/_utils.py @@ -1,8 +1,11 @@ import warnings -from typing import Callable, Optional, Union +from typing import Callable, Optional, Protocol, Union, Dict, Any import pystac from pystac_client.errors import IgnoredResultWarning +from pystac_client.exceptions import APIError +from pystac_client.conformance import ConformanceClasses +from pystac_client.stac_api_io import StacApiIO Modifiable = Union[pystac.Collection, pystac.Item, pystac.ItemCollection, dict] @@ -23,3 +26,37 @@ def call_modifier( "a function that returns 'None' or silence this warning.", IgnoredResultWarning, ) + + +class StacAPIProtocol(Protocol): + _stac_io: Optional[StacApiIO] + + def get_self_href(self) -> str: + ... + + +class QueryableMixin: + def get_queryables(self: StacAPIProtocol) -> Dict[str, Any]: + """Return all queryables. + + Output is a dictionary that can be used in ``jsonshema.validate`` + + Return: + Dict[str, Any]: Dictionary containing queryable fields + """ + if self._stac_io is None: + raise APIError("API access is not properly configured") + + self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) + + self_href = self.get_self_href() + if self_href is None: + raise ValueError("Cannot build a queryable href without a self href") + + url = f"{self_href.rstrip('/')}/queryables" + + result = self._stac_io.read_json(url) + if "properties" not in result: + raise APIError("Invalid response from /queryables") + + return result diff --git a/pystac_client/client.py b/pystac_client/client.py index 205fb2ec..ad954877 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -7,7 +7,7 @@ from pystac import CatalogType, Collection from requests import Request -from pystac_client._utils import Modifiable, call_modifier +from pystac_client._utils import Modifiable, call_modifier, QueryableMixin from pystac_client.collection_client import CollectionClient from pystac_client.conformance import ConformanceClasses from pystac_client.errors import ClientTypeError @@ -32,7 +32,7 @@ from pystac.item import Item as Item_Type -class Client(pystac.Catalog): +class Client(pystac.Catalog, QueryableMixin): """A Client for interacting with the root of a STAC Catalog or API Instances of the ``Client`` class inherit from :class:`pystac.Catalog` @@ -499,29 +499,6 @@ def get_search_link(self) -> Optional[pystac.Link]: None, ) - def get_queryables(self) -> Dict[str, Any]: - """Return all queryables. - - Output is a dictionary that can be used in ``jsonshema.validate`` - - Return: - Dict[str, Any]: Dictionary containing queryable fields - """ - assert self._stac_io is not None - self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) - - self_href = self.get_self_href() - if self_href is None: - raise ValueError("cannot build a queryable href without a self href") - - url = f"{self_href.rstrip('/')}/queryables" - - result = self._stac_io.read_json(url) - if "properties" not in result: - raise APIError("Invalid response from /queryables") - - return result - def _get_collections_href(self, collection_id: Optional[str] = None) -> str: self_href = self.get_self_href() if self_href is None: diff --git a/pystac_client/collection_client.py b/pystac_client/collection_client.py index b92e6375..d962624f 100644 --- a/pystac_client/collection_client.py +++ b/pystac_client/collection_client.py @@ -2,7 +2,7 @@ import pystac -from pystac_client._utils import Modifiable, call_modifier +from pystac_client._utils import Modifiable, call_modifier, QueryableMixin from pystac_client.conformance import ConformanceClasses from pystac_client.exceptions import APIError from pystac_client.item_search import ItemSearch @@ -12,8 +12,9 @@ from pystac.item import Item as Item_Type -class CollectionClient(pystac.Collection): +class CollectionClient(pystac.Collection, QueryableMixin): modifier: Callable[[Modifiable], None] + _stac_io: Optional[StacApiIO] def __init__( self, @@ -69,6 +70,15 @@ def from_dict( setattr(result, "modifier", modifier) return result + def set_root(self, root: Optional[pystac.Catalog]) -> None: + # hook in to set_root and use it for setting _stac_io + super().set_root(root=root) + if root is not None and root._stac_io is not None: + if not isinstance(root._stac_io, StacApiIO): + raise ValueError("Root should be a Client object") + else: + self._stac_io = root._stac_io + def __repr__(self) -> str: return "".format(self.id) @@ -173,31 +183,3 @@ def get_item(self, id: str, recursive: bool = False) -> Optional["Item_Type"]: call_modifier(self.modifier, item) return item - - def get_queryables(self) -> Dict[str, Any]: - """Return all queryables. - - Output is a dictionary that can be used in ``jsonshema.validate`` - - Return: - Dict[str, Any]: Dictionary containing queryable fields - """ - root = self.get_root() - assert root - stac_io = root._stac_io - assert stac_io - assert isinstance(stac_io, StacApiIO) - - stac_io.assert_conforms_to(ConformanceClasses.FILTER) - - self_href = self.get_self_href() - if self_href is None: - raise ValueError("cannot build a queryable href without a self href") - - url = f"{self_href.rstrip('/')}/queryables" - - result = stac_io.read_json(url) - if "properties" not in result: - raise APIError("Invalid response from /queryables") - - return result From b78d5de578dda10190ad0576f42562aad7f8c1e9 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Thu, 13 Apr 2023 11:00:47 -0400 Subject: [PATCH 3/9] Move Mixin to its own file --- pystac_client/_utils.py | 39 +------------- pystac_client/client.py | 5 +- pystac_client/collection_client.py | 85 +++++++++++++----------------- pystac_client/mixins.py | 56 ++++++++++++++++++++ 4 files changed, 97 insertions(+), 88 deletions(-) create mode 100644 pystac_client/mixins.py diff --git a/pystac_client/_utils.py b/pystac_client/_utils.py index d9b68778..cda15a9b 100644 --- a/pystac_client/_utils.py +++ b/pystac_client/_utils.py @@ -1,11 +1,8 @@ import warnings -from typing import Callable, Optional, Protocol, Union, Dict, Any +from typing import Callable, Optional, Union import pystac from pystac_client.errors import IgnoredResultWarning -from pystac_client.exceptions import APIError -from pystac_client.conformance import ConformanceClasses -from pystac_client.stac_api_io import StacApiIO Modifiable = Union[pystac.Collection, pystac.Item, pystac.ItemCollection, dict] @@ -26,37 +23,3 @@ def call_modifier( "a function that returns 'None' or silence this warning.", IgnoredResultWarning, ) - - -class StacAPIProtocol(Protocol): - _stac_io: Optional[StacApiIO] - - def get_self_href(self) -> str: - ... - - -class QueryableMixin: - def get_queryables(self: StacAPIProtocol) -> Dict[str, Any]: - """Return all queryables. - - Output is a dictionary that can be used in ``jsonshema.validate`` - - Return: - Dict[str, Any]: Dictionary containing queryable fields - """ - if self._stac_io is None: - raise APIError("API access is not properly configured") - - self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) - - self_href = self.get_self_href() - if self_href is None: - raise ValueError("Cannot build a queryable href without a self href") - - url = f"{self_href.rstrip('/')}/queryables" - - result = self._stac_io.read_json(url) - if "properties" not in result: - raise APIError("Invalid response from /queryables") - - return result diff --git a/pystac_client/client.py b/pystac_client/client.py index ad954877..a9b7f743 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -7,7 +7,7 @@ from pystac import CatalogType, Collection from requests import Request -from pystac_client._utils import Modifiable, call_modifier, QueryableMixin +from pystac_client._utils import Modifiable, call_modifier from pystac_client.collection_client import CollectionClient from pystac_client.conformance import ConformanceClasses from pystac_client.errors import ClientTypeError @@ -26,13 +26,14 @@ QueryLike, SortbyLike, ) +from pystac_client.mixins import QueryablesMixin from pystac_client.stac_api_io import StacApiIO if TYPE_CHECKING: from pystac.item import Item as Item_Type -class Client(pystac.Catalog, QueryableMixin): +class Client(pystac.Catalog, QueryablesMixin): """A Client for interacting with the root of a STAC Catalog or API Instances of the ``Client`` class inherit from :class:`pystac.Catalog` diff --git a/pystac_client/collection_client.py b/pystac_client/collection_client.py index d962624f..bfe30bd2 100644 --- a/pystac_client/collection_client.py +++ b/pystac_client/collection_client.py @@ -2,17 +2,18 @@ import pystac -from pystac_client._utils import Modifiable, call_modifier, QueryableMixin +from pystac_client._utils import Modifiable, call_modifier from pystac_client.conformance import ConformanceClasses from pystac_client.exceptions import APIError from pystac_client.item_search import ItemSearch +from pystac_client.mixins import QueryablesMixin from pystac_client.stac_api_io import StacApiIO if TYPE_CHECKING: from pystac.item import Item as Item_Type -class CollectionClient(pystac.Collection, QueryableMixin): +class CollectionClient(pystac.Collection, QueryablesMixin): modifier: Callable[[Modifiable], None] _stac_io: Optional[StacApiIO] @@ -94,19 +95,11 @@ def get_items(self) -> Iterator["Item_Type"]: """ link = self.get_single_link("items") - root = self.get_root() - if link is not None and root is not None: - # error: Argument "stac_io" to "ItemSearch" has incompatible type - # "Optional[StacIO]"; expected "Optional[StacApiIO]" [arg-type] - # so we add these asserts - stac_io = root._stac_io - assert stac_io - assert isinstance(stac_io, StacApiIO) - + if link is not None and self._stac_io is not None: search = ItemSearch( url=link.href, method="GET", - stac_io=stac_io, + stac_io=self._stac_io, modifier=self.modifier, ) yield from search.items() @@ -137,43 +130,39 @@ def get_item(self, id: str, recursive: bool = False) -> Optional["Item_Type"]: """ if not recursive: root = self.get_root() - assert root - stac_io = root._stac_io - assert stac_io - assert isinstance(stac_io, StacApiIO) - items_link = self.get_single_link("items") - if root: + if root and self._stac_io: + items_link = self.get_single_link("items") search_link = root.get_single_link("search") - else: - search_link = None - if ( - stac_io.conforms_to(ConformanceClasses.FEATURES) - and items_link is not None - ): - url = f"{items_link.href}/{id}" - try: - obj = stac_io.read_stac_object(url, root=self) - item = cast(Optional[pystac.Item], obj) - except APIError as err: - if err.status_code and err.status_code == 404: - return None - else: - raise err - assert isinstance(item, pystac.Item) - elif ( - stac_io.conforms_to(ConformanceClasses.ITEM_SEARCH) - and search_link - and search_link.href - ): - item_search = ItemSearch( - url=search_link.href, - method="GET", - stac_io=stac_io, - ids=[id], - collections=[self.id], - modifier=self.modifier, - ) - item = next(item_search.items(), None) + if ( + self._stac_io.conforms_to(ConformanceClasses.FEATURES) + and items_link is not None + ): + url = f"{items_link.href}/{id}" + try: + obj = self._stac_io.read_stac_object(url, root=self) + item = cast(Optional[pystac.Item], obj) + except APIError as err: + if err.status_code and err.status_code == 404: + return None + else: + raise err + assert isinstance(item, pystac.Item) + elif ( + self._stac_io.conforms_to(ConformanceClasses.ITEM_SEARCH) + and search_link + and search_link.href + ): + item_search = ItemSearch( + url=search_link.href, + method="GET", + stac_io=self._stac_io, + ids=[id], + collections=[self.id], + modifier=self.modifier, + ) + item = next(item_search.items(), None) + else: + item = super().get_item(id, recursive=False) else: item = super().get_item(id, recursive=False) else: diff --git a/pystac_client/mixins.py b/pystac_client/mixins.py new file mode 100644 index 00000000..b6014224 --- /dev/null +++ b/pystac_client/mixins.py @@ -0,0 +1,56 @@ +from typing import Optional, Protocol, Dict, Any, Union +import pystac +from pystac_client.exceptions import APIError +from pystac_client.conformance import ConformanceClasses +from pystac_client.stac_api_io import StacApiIO + +QUERYABLES_REL = "http://www.opengis.net/def/rel/ogc/1.0/queryables" +QUERYABLES_ENDPOINT = "/queryables" + + +class StacAPIProtocol(Protocol): + _stac_io: Optional[StacApiIO] + + def get_self_href(self) -> str: + ... + + def get_single_link( + self, + rel: Optional[Union[str, pystac.RelType]] = None, + media_type: Optional[Union[str, pystac.MediaType]] = None, + ) -> Optional[pystac.Link]: + ... + + +class QueryablesMixin: + """Mixin for adding support for /queryables endpoint""" + + def get_queryables(self: StacAPIProtocol) -> Dict[str, Any]: + """Return all queryables. + + Output is a dictionary that can be used in ``jsonshema.validate`` + + Return: + Dict[str, Any]: Dictionary containing queryable fields + """ + if self._stac_io is None: + raise APIError("API access is not properly configured") + + self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) + + # The queryables link should be defined at the root, but if it is not + # try to guess the url + link = self.get_single_link(QUERYABLES_REL) + if link is not None: + url = link.href + else: + self_href = self.get_self_href() + if self_href is None: + raise ValueError("Cannot build a queryable href without a self href") + url = f"{self_href.rstrip('/')}{QUERYABLES_ENDPOINT}" + + result = self._stac_io.read_json(url) + if "properties" not in result: + raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}") + + return result From faa0143ceef4df64bd2eaee2d4c42b2efed1aa8c Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Thu, 13 Apr 2023 11:17:56 -0400 Subject: [PATCH 4/9] Add test for _stac_io is None --- pystac_client/mixins.py | 24 +++++++++++++----------- tests/test_client.py | 7 ++++++- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/pystac_client/mixins.py b/pystac_client/mixins.py index b6014224..cd9dede8 100644 --- a/pystac_client/mixins.py +++ b/pystac_client/mixins.py @@ -11,7 +11,7 @@ class StacAPIProtocol(Protocol): _stac_io: Optional[StacApiIO] - def get_self_href(self) -> str: + def get_self_href(self) -> Optional[str]: ... def get_single_link( @@ -22,10 +22,10 @@ def get_single_link( ... -class QueryablesMixin: +class QueryablesMixin(StacAPIProtocol): """Mixin for adding support for /queryables endpoint""" - def get_queryables(self: StacAPIProtocol) -> Dict[str, Any]: + def get_queryables(self) -> Dict[str, Any]: """Return all queryables. Output is a dictionary that can be used in ``jsonshema.validate`` @@ -37,20 +37,22 @@ def get_queryables(self: StacAPIProtocol) -> Dict[str, Any]: raise APIError("API access is not properly configured") self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) + url = self._get_queryables_href() + result = self._stac_io.read_json(url) + if "properties" not in result: + raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}") - # The queryables link should be defined at the root, but if it is not - # try to guess the url + return result + + def _get_queryables_href(self) -> str: link = self.get_single_link(QUERYABLES_REL) if link is not None: url = link.href else: + # The queryables link should be defined at the root, but if it is not + # try to guess the url self_href = self.get_self_href() if self_href is None: raise ValueError("Cannot build a queryable href without a self href") url = f"{self_href.rstrip('/')}{QUERYABLES_ENDPOINT}" - - result = self._stac_io.read_json(url) - if "properties" not in result: - raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}") - - return result + return url diff --git a/tests/test_client.py b/tests/test_client.py index b9225467..d6d664af 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,6 +16,7 @@ from pystac_client._utils import Modifiable from pystac_client.conformance import ConformanceClasses from pystac_client.errors import ClientTypeError, IgnoredResultWarning +from pystac_client.exceptions import APIError from pystac_client.stac_api_io import StacApiIO from .helpers import STAC_URLS, TEST_DATA, read_data_file @@ -617,7 +618,7 @@ def test_get_queryables(self) -> None: assert "properties" in result assert "id" in result["properties"] - def test_get_queryables_fails_if_no_self_link(self, requests_mock: Mocker) -> None: + def test_get_queryables_errors(self, requests_mock: Mocker) -> None: pc_root_text = read_data_file("planetary-computer-root.json") root_url = "http://pystac-client.test/" requests_mock.get(root_url, status_code=200, text=pc_root_text) @@ -630,3 +631,7 @@ def test_get_queryables_fails_if_no_self_link(self, requests_mock: Mocker) -> No api.set_self_href(None) with pytest.raises(ValueError, match="queryable href without a self href"): api.get_queryables() + + api._stac_io = None + with pytest.raises(APIError, match="API access is not properly configured"): + api.get_queryables() From 0c4803e76b3b17de957d184506f4458265e9b825 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Thu, 13 Apr 2023 11:23:39 -0400 Subject: [PATCH 5/9] Just use pystac.STACObject --- pystac_client/mixins.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/pystac_client/mixins.py b/pystac_client/mixins.py index cd9dede8..be85302a 100644 --- a/pystac_client/mixins.py +++ b/pystac_client/mixins.py @@ -1,4 +1,4 @@ -from typing import Optional, Protocol, Dict, Any, Union +from typing import Optional, Dict, Any import pystac from pystac_client.exceptions import APIError from pystac_client.conformance import ConformanceClasses @@ -8,21 +8,11 @@ QUERYABLES_ENDPOINT = "/queryables" -class StacAPIProtocol(Protocol): +class StacAPIObject(pystac.STACObject): _stac_io: Optional[StacApiIO] - def get_self_href(self) -> Optional[str]: - ... - def get_single_link( - self, - rel: Optional[Union[str, pystac.RelType]] = None, - media_type: Optional[Union[str, pystac.MediaType]] = None, - ) -> Optional[pystac.Link]: - ... - - -class QueryablesMixin(StacAPIProtocol): +class QueryablesMixin(StacAPIObject): """Mixin for adding support for /queryables endpoint""" def get_queryables(self) -> Dict[str, Any]: From 86c34c11fec4fa7b8290fd0141ba3f71e55e4f53 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Thu, 13 Apr 2023 11:36:32 -0400 Subject: [PATCH 6/9] Tidy up --- pystac_client/mixins.py | 6 ++---- tests/test_client.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pystac_client/mixins.py b/pystac_client/mixins.py index be85302a..9f1de84f 100644 --- a/pystac_client/mixins.py +++ b/pystac_client/mixins.py @@ -28,6 +28,7 @@ def get_queryables(self) -> Dict[str, Any]: self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) url = self._get_queryables_href() + result = self._stac_io.read_json(url) if "properties" not in result: raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}") @@ -41,8 +42,5 @@ def _get_queryables_href(self) -> str: else: # The queryables link should be defined at the root, but if it is not # try to guess the url - self_href = self.get_self_href() - if self_href is None: - raise ValueError("Cannot build a queryable href without a self href") - url = f"{self_href.rstrip('/')}{QUERYABLES_ENDPOINT}" + url = f"{self.self_href.rstrip('/')}{QUERYABLES_ENDPOINT}" return url diff --git a/tests/test_client.py b/tests/test_client.py index d6d664af..fdcf2bc5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -629,7 +629,7 @@ def test_get_queryables_errors(self, requests_mock: Mocker) -> None: assert api._stac_io is not None api._stac_io._conformance = None api.set_self_href(None) - with pytest.raises(ValueError, match="queryable href without a self href"): + with pytest.raises(ValueError, match="does not have a self_href set"): api.get_queryables() api._stac_io = None From 65e2133a1446d41d0656994269c5c07550488370 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Mon, 17 Apr 2023 09:59:17 -0400 Subject: [PATCH 7/9] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd04b52..0c6ad2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `query` parameter in GET requests [#362](https://github.com/stac-utils/pystac-client/pull/362) - Double encoding of `intersects` parameter in GET requests [#362](https://github.com/stac-utils/pystac-client/pull/362) +### Added + +- Support for fetching catalog queryables [#477](https://github.com/stac-utils/pystac-client/pull/477) + ## [v0.6.1] - 2023-03-14 ### Changed From 9ba1647669786ce38648b9ad0522e0934d65a1a8 Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Wed, 19 Apr 2023 11:01:56 -0400 Subject: [PATCH 8/9] Apply suggestions from code review Co-authored-by: Pete Gadomski --- pystac_client/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pystac_client/mixins.py b/pystac_client/mixins.py index 9f1de84f..0ceb43b2 100644 --- a/pystac_client/mixins.py +++ b/pystac_client/mixins.py @@ -31,7 +31,7 @@ def get_queryables(self) -> Dict[str, Any]: result = self._stac_io.read_json(url) if "properties" not in result: - raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}") + raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}: expected 'properties' attribute") return result From 56fc9b6333aa43dfefe04abf20e072840d78ad8f Mon Sep 17 00:00:00 2001 From: Julia Signell Date: Wed, 19 Apr 2023 11:15:26 -0400 Subject: [PATCH 9/9] Lint --- pystac_client/_utils.py | 2 +- pystac_client/mixins.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pystac_client/_utils.py b/pystac_client/_utils.py index cda15a9b..f2dccb35 100644 --- a/pystac_client/_utils.py +++ b/pystac_client/_utils.py @@ -1,10 +1,10 @@ import warnings from typing import Callable, Optional, Union + import pystac from pystac_client.errors import IgnoredResultWarning - Modifiable = Union[pystac.Collection, pystac.Item, pystac.ItemCollection, dict] diff --git a/pystac_client/mixins.py b/pystac_client/mixins.py index 0ceb43b2..7e521c88 100644 --- a/pystac_client/mixins.py +++ b/pystac_client/mixins.py @@ -1,5 +1,7 @@ from typing import Optional, Dict, Any + import pystac + from pystac_client.exceptions import APIError from pystac_client.conformance import ConformanceClasses from pystac_client.stac_api_io import StacApiIO @@ -31,7 +33,10 @@ def get_queryables(self) -> Dict[str, Any]: result = self._stac_io.read_json(url) if "properties" not in result: - raise APIError(f"Invalid response from {QUERYABLES_ENDPOINT}: expected 'properties' attribute") + raise APIError( + f"Invalid response from {QUERYABLES_ENDPOINT}: " + "expected 'properties' attribute" + ) return result