diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c89dc12..48937640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Search query parameter now has correct typing and handles Query Extension JSON format. [#220](https://github.com/stac-utils/pystac-client/pull/220) - Search sortby parameter now has correct typing and handles both GET and POST JSON parameter formats. [#175](https://github.com/stac-utils/pystac-client/pull/175) - Search fields parameter now has correct typing and handles both GET and POST JSON parameter formats. [#184](https://github.com/stac-utils/pystac-client/pull/184) -- Use pytest configuration to skip benchmarks by default (instead of a `skip` mark) [#168](https://github.com/stac-utils/pystac-client/pull/168) +- Use pytest configuration to skip benchmarks by default (instead of a `skip` mark). [#168](https://github.com/stac-utils/pystac-client/pull/168) +- Methods retrieving collections incorrectly checked the existence of the OAFeat OpenAPI 3.0 conformance class + (http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30) instead of the `STAC API - Collections` + (https://api.stacspec.org/v1.0.0-beta.1/collections) or `STAC API - Features` + (https://api.stacspec.org/v1.0.0-beta.1/ogcapi-features) conformance classes. [223](https://github.com/stac-utils/pystac-client/pull/223) ## [v0.3.5] - 2022-05-26 diff --git a/pystac_client/client.py b/pystac_client/client.py index 4e51a604..f3807494 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -30,14 +30,14 @@ class Client(pystac.Catalog): such as searching items (e.g., /search endpoint). """ - def __repr__(self): + def __repr__(self) -> str: return "".format(self.id) @classmethod def open( cls, url: str, - headers: Dict[str, str] = None, + headers: Optional[Dict[str, str]] = None, parameters: Optional[Dict[str, Any]] = None, ignore_conformance: bool = False, ) -> "Client": @@ -49,6 +49,8 @@ def open( `STAC_URL` environment variable. headers : A dictionary of additional headers to use in all requests made to any part of this Catalog/API. + parameters: Optional dictionary of query string parameters to + include in all requests. ignore_conformance : Ignore any advertised Conformance Classes in this Catalog/API. This means that functions will skip checking conformance, and may throw an unknown @@ -77,8 +79,8 @@ def from_file( cls, href: str, stac_io: Optional[pystac.StacIO] = None, - headers: Optional[Dict] = {}, - parameters: Optional[Dict] = None, + headers: Optional[Dict[str, str]] = None, + parameters: Optional[Dict[str, Any]] = None, ) -> "Client": """Open a STAC Catalog/API @@ -94,6 +96,15 @@ def from_file( return cat + def _supports_collections(self) -> bool: + return self._conforms_to(ConformanceClasses.COLLECTIONS) or self._conforms_to( + ConformanceClasses.FEATURES + ) + + # TODO: fix this with the stac_api_io() method in a future PR + def _conforms_to(self, conformance_class: ConformanceClasses) -> bool: + return self._stac_io.conforms_to(conformance_class) # type: ignore + @classmethod def from_dict( cls, @@ -123,7 +134,7 @@ def get_collection(self, collection_id: str) -> CollectionClient: Returns: CollectionClient: A STAC Collection """ - if self._stac_io.conforms_to(ConformanceClasses.COLLECTIONS): + if self._supports_collections(): url = f"{self.get_self_href()}/collections/{collection_id}" collection = CollectionClient.from_dict( self._stac_io.read_json(url), root=self @@ -143,7 +154,7 @@ def get_collections(self) -> Iterable[CollectionClient]: Return: Iterable[CollectionClient]: Iterator through Collections in Catalog/API """ - if self._stac_io.conforms_to(ConformanceClasses.COLLECTIONS): + if self._supports_collections(): url = self.get_self_href() + "/collections" for page in self._stac_io.get_pages(url): if "collections" not in page: @@ -160,7 +171,7 @@ def get_items(self) -> Iterable["Item_Type"]: Return: Iterable[Item]:: Generator of items whose parent is this catalog. """ - if self._stac_io.conforms_to(ConformanceClasses.ITEM_SEARCH): + if self._conforms_to(ConformanceClasses.ITEM_SEARCH): search = self.search() yield from search.items() else: @@ -175,7 +186,7 @@ def get_all_items(self) -> Iterable["Item_Type"]: catalogs or collections connected to this catalog through child links. """ - if self._stac_io.conforms_to(ConformanceClasses.ITEM_SEARCH): + if self._conforms_to(ConformanceClasses.ITEM_SEARCH): yield from self.get_items() else: yield from super().get_items() @@ -211,7 +222,7 @@ def search(self, **kwargs: Any) -> ItemSearch: or does not have a link with a ``"rel"`` type of ``"search"``. """ - if not self._stac_io.conforms_to(ConformanceClasses.ITEM_SEARCH): + if not self._conforms_to(ConformanceClasses.ITEM_SEARCH): raise NotImplementedError( "This catalog does not support search because it " f'does not conform to "{ConformanceClasses.ITEM_SEARCH}"' @@ -223,7 +234,10 @@ def search(self, **kwargs: Any) -> ItemSearch: ) return ItemSearch( - search_link.target, stac_io=self._stac_io, client=self, **kwargs + search_link.target, + stac_io=self._stac_io, + client=self, + **kwargs, ) def get_search_link(self) -> Optional[pystac.Link]: diff --git a/pystac_client/collection_client.py b/pystac_client/collection_client.py index cee3cbbe..727062e8 100644 --- a/pystac_client/collection_client.py +++ b/pystac_client/collection_client.py @@ -58,10 +58,7 @@ def get_item(self, id: str, recursive: bool = False) -> Optional["Item_Type"]: assert stac_io assert isinstance(stac_io, StacApiIO) link = self.get_single_link("items") - if ( - stac_io.conforms_to(ConformanceClasses.OGCAPI_FEATURES) - and link is not None - ): + if stac_io.conforms_to(ConformanceClasses.FEATURES) and link is not None: url = f"{link.href}/{id}" try: item = stac_io.read_stac_object(url, root=self) diff --git a/pystac_client/conformance.py b/pystac_client/conformance.py index 9b455d02..9566be2f 100644 --- a/pystac_client/conformance.py +++ b/pystac_client/conformance.py @@ -9,16 +9,18 @@ class ConformanceClasses(Enum): # defined conformance classes regexes CORE = rf"{stac_prefix}(.*){re.escape('/core')}" + COLLECTIONS = rf"{stac_prefix}(.*){re.escape('/collections')}" + + # this is ogcapi-features instead of just features for historical reasons, + # even thought this is a STAC API conformance class + FEATURES = rf"{stac_prefix}(.*){re.escape('/ogcapi-features')}" ITEM_SEARCH = rf"{stac_prefix}(.*){re.escape('/item-search')}" + CONTEXT = rf"{stac_prefix}(.*){re.escape('/item-search#context')}" FIELDS = rf"{stac_prefix}(.*){re.escape('/item-search#fields')}" SORT = rf"{stac_prefix}(.*){re.escape('/item-search#sort')}" QUERY = rf"{stac_prefix}(.*){re.escape('/item-search#query')}" FILTER = rf"{stac_prefix}(.*){re.escape('/item-search#filter')}" - COLLECTIONS = re.escape( - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30" - ) - OGCAPI_FEATURES = rf"{stac_prefix}(.*){re.escape('/ogcapi-features')}" CONFORMANCE_URIS = {c.name: c.value for c in ConformanceClasses} diff --git a/pystac_client/item_search.py b/pystac_client/item_search.py index c4694216..483d165c 100644 --- a/pystac_client/item_search.py +++ b/pystac_client/item_search.py @@ -57,7 +57,7 @@ QueryLike = Union[Query, List[str]] FilterLangLike = str -FilterLike = Union[dict, str] +FilterLike = Union[Dict[str, Any], str] Sortby = List[Dict[str, str]] SortbyLike = Union[Sortby, str, List[str]] @@ -240,7 +240,8 @@ def __init__( self._stac_io = stac_io else: self._stac_io = StacApiIO() - self._stac_io.assert_conforms_to(ConformanceClasses.ITEM_SEARCH) + + self._assert_conforms_to(ConformanceClasses.ITEM_SEARCH) self._max_items = max_items if self._max_items is not None and limit is not None: @@ -267,6 +268,10 @@ def __init__( self._parameters = {k: v for k, v in params.items() if v is not None} + # TODO: fix this with the stac_api_io() method in a future PR + def _assert_conforms_to(self, conformance_class: ConformanceClasses) -> None: + self._stac_io.assert_conforms_to(conformance_class) # type: ignore + def get_parameters(self) -> Dict[str, Any]: if self.method == "POST": return self._parameters @@ -281,18 +286,20 @@ def get_parameters(self) -> Dict[str, Any]: if "intersects" in params: params["intersects"] = json.dumps(params["intersects"]) if "sortby" in params: - params["sortby"] = self.sortby_dict_to_str(params["sortby"]) + params["sortby"] = self._sortby_dict_to_str(params["sortby"]) if "fields" in params: - params["fields"] = self.fields_dict_to_str(params["fields"]) + params["fields"] = self._fields_dict_to_str(params["fields"]) return params else: raise Exception(f"Unsupported method {self.method}") - @staticmethod - def _format_query(value: QueryLike) -> Optional[Dict[str, Any]]: + def _format_query(self, value: QueryLike) -> Optional[Dict[str, Any]]: if value is None: return None - elif isinstance(value, dict): + + self._assert_conforms_to(ConformanceClasses.QUERY) + + if isinstance(value, dict): return value elif isinstance(value, list): query: Dict[str, Any] = {} @@ -319,7 +326,7 @@ def _format_query(value: QueryLike) -> Optional[Dict[str, Any]]: @staticmethod def _format_filter_lang( - _filter: FilterLike, value: FilterLangLike + _filter: Optional[FilterLike], value: Optional[FilterLangLike] ) -> Optional[str]: if _filter is None: return None @@ -335,11 +342,12 @@ def _format_filter_lang( return None - def _format_filter(self, value: FilterLike) -> Optional[dict]: + def _format_filter(self, value: Optional[FilterLike]) -> Optional[FilterLike]: if value is None: return None - self._stac_io.assert_conforms_to(ConformanceClasses.FILTER) + self._assert_conforms_to(ConformanceClasses.FILTER) + return value @staticmethod @@ -475,14 +483,14 @@ def _format_sortby(self, value: Optional[SortbyLike]) -> Optional[Sortby]: if value is None: return None - self._stac_io.assert_conforms_to(ConformanceClasses.SORT) + self._assert_conforms_to(ConformanceClasses.SORT) if isinstance(value, str): - return [self.sortby_part_to_dict(part) for part in value.split(",")] + return [self._sortby_part_to_dict(part) for part in value.split(",")] if isinstance(value, list): if value and isinstance(value[0], str): - return [self.sortby_part_to_dict(v) for v in value] + return [self._sortby_part_to_dict(v) for v in value] elif value and isinstance(value[0], dict): return value @@ -491,7 +499,7 @@ def _format_sortby(self, value: Optional[SortbyLike]) -> Optional[Sortby]: ) @staticmethod - def sortby_part_to_dict(part: str) -> Dict[str, str]: + def _sortby_part_to_dict(part: str) -> Dict[str, str]: if part.startswith("-"): return {"field": part[1:], "direction": "desc"} elif part.startswith("+"): @@ -500,7 +508,7 @@ def sortby_part_to_dict(part: str) -> Dict[str, str]: return {"field": part, "direction": "asc"} @staticmethod - def sortby_dict_to_str(sortby: Sortby) -> str: + def _sortby_dict_to_str(sortby: Sortby) -> str: return ",".join( [ f"{'+' if sort['direction'] == 'asc' else '-'}{sort['field']}" @@ -512,12 +520,12 @@ def _format_fields(self, value: Optional[FieldsLike]) -> Optional[Fields]: if value is None: return None - self._stac_io.assert_conforms_to(ConformanceClasses.FIELDS) + self._assert_conforms_to(ConformanceClasses.FIELDS) if isinstance(value, str): - return self.fields_to_dict(value.split(",")) + return self._fields_to_dict(value.split(",")) if isinstance(value, list): - return self.fields_to_dict(value) + return self._fields_to_dict(value) if isinstance(value, dict): return value @@ -526,7 +534,7 @@ def _format_fields(self, value: Optional[FieldsLike]) -> Optional[Fields]: ) @staticmethod - def fields_to_dict(fields: List[str]) -> Fields: + def _fields_to_dict(fields: List[str]) -> Fields: includes: List[str] = [] excludes: List[str] = [] for field in fields: @@ -539,7 +547,7 @@ def fields_to_dict(fields: List[str]) -> Fields: return {"includes": includes, "excludes": excludes} @staticmethod - def fields_dict_to_str(fields: Fields) -> str: + def _fields_dict_to_str(fields: Fields) -> str: includes = [f"+{x}" for x in fields.get("includes", [])] excludes = [f"-{x}" for x in fields.get("excludes", [])] return ",".join(chain(includes, excludes)) diff --git a/tests/data/planetary-computer-root.json b/tests/data/planetary-computer-root.json index e0aaf796..3fb25cfb 100644 --- a/tests/data/planetary-computer-root.json +++ b/tests/data/planetary-computer-root.json @@ -59,6 +59,7 @@ ], "conformsTo": [ "https://api.stacspec.org/v1.0.0-beta.1/core", + "https://api.stacspec.org/v1.0.0-beta.1/collections", "https://api.stacspec.org/v1.0.0-beta.1/item-search", "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", diff --git a/tests/test_client.py b/tests/test_client.py index 64172b35..f6f4146b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -295,7 +295,7 @@ def test_get_collections_without_conformance(self, requests_mock): # Remove the collections conformance class pc_root_dict["conformsTo"].remove( - "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30" + "https://api.stacspec.org/v1.0.0-beta.1/collections" ) # Remove all child links except for the collection that we are mocking diff --git a/tests/test_item_search.py b/tests/test_item_search.py index 6633e0b1..d6f6fd7e 100644 --- a/tests/test_item_search.py +++ b/tests/test_item_search.py @@ -667,15 +667,16 @@ def test_query_json_syntax(self) -> None: def test_query_json_syntax() -> None: - assert ItemSearch._format_query(['{"eo:cloud_cover": { "gte": 0, "lte": 1 }}']) == { + item_search = ItemSearch("") + assert item_search._format_query( + ['{"eo:cloud_cover": { "gte": 0, "lte": 1 }}'] + ) == {"eo:cloud_cover": {"gte": 0, "lte": 1}} + assert item_search._format_query({"eo:cloud_cover": {"gte": 0, "lte": 1}}) == { "eo:cloud_cover": {"gte": 0, "lte": 1} } - assert ItemSearch._format_query({"eo:cloud_cover": {"gte": 0, "lte": 1}}) == { - "eo:cloud_cover": {"gte": 0, "lte": 1} - } - assert ItemSearch._format_query(["eo:cloud_cover<=1"]) == { + assert item_search._format_query(["eo:cloud_cover<=1"]) == { "eo:cloud_cover": {"lte": "1"} } - assert ItemSearch._format_query(["eo:cloud_cover<=1", "eo:cloud_cover>0"]) == { + assert item_search._format_query(["eo:cloud_cover<=1", "eo:cloud_cover>0"]) == { "eo:cloud_cover": {"lte": "1", "gt": "0"} }