From 5c4764953f6575be1f2136e824ca89ad04f5045f Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Fri, 3 Jun 2022 12:53:33 -0400 Subject: [PATCH 1/6] fix support for query extension passing in JSON --- CHANGELOG.md | 5 +- pystac_client/item_search.py | 39 ++-- ...temSearchQuery.test_query_json_syntax.yaml | 208 ++++++++++++++++++ tests/test_item_search.py | 40 ++++ 4 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 tests/cassettes/test_item_search/TestItemSearchQuery.test_query_json_syntax.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc89a52..9329d47c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,8 +46,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed -- Search sortby parameter now has correct typing and correctly 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 correctly handles both GET and POST JSON parameter formats. [#184](https://github.com/stac-utils/pystac-client/pull/184) +- Search query parameter now has correct typing and handles Query Extension JSON format. []() +- 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) ## [v0.3.5] - 2022-05-26 diff --git a/pystac_client/item_search.py b/pystac_client/item_search.py index 73f201e8..c4694216 100644 --- a/pystac_client/item_search.py +++ b/pystac_client/item_search.py @@ -49,11 +49,11 @@ IDs = Tuple[str, ...] IDsLike = Union[IDs, str, List[str], Iterator[str]] -Intersects = dict +Intersects = Dict[str, Any] IntersectsLike = Union[str, object, Intersects] # todo: after 3.7 is dropped, replace object with GeoInterface -Query = dict +Query = Dict[str, Any] QueryLike = Union[Query, List[str]] FilterLangLike = str @@ -289,24 +289,31 @@ def get_parameters(self) -> Dict[str, Any]: raise Exception(f"Unsupported method {self.method}") @staticmethod - def _format_query(value: List[QueryLike]) -> Optional[Dict[str, Any]]: + def _format_query(value: QueryLike) -> Optional[Dict[str, Any]]: if value is None: return None - - if isinstance(value, list): - query = {} + elif isinstance(value, dict): + return value + elif isinstance(value, list): + query: Dict[str, Any] = {} for q in value: - for op in OPS: - parts = q.split(op) - if len(parts) == 2: - param = parts[0] - val = parts[1] - if param == "gsd": - val = float(val) - query = dict_merge(query, {parts[0]: {OP_MAP[op]: val}}) - break + if isinstance(q, str): + try: + query = dict_merge(query, json.loads(q)) + except json.decoder.JSONDecodeError: + for op in OPS: + parts = q.split(op) + if len(parts) == 2: + param = parts[0] + val: Union[str, float] = parts[1] + if param == "gsd": + val = float(val) + query = dict_merge(query, {parts[0]: {OP_MAP[op]: val}}) + break + else: + raise Exception("Unsupported query format, must be a List[str].") else: - query = value + raise Exception("Unsupported query format, must be a Dict or List[str].") return query diff --git a/tests/cassettes/test_item_search/TestItemSearchQuery.test_query_json_syntax.yaml b/tests/cassettes/test_item_search/TestItemSearchQuery.test_query_json_syntax.yaml new file mode 100644 index 00000000..2590006d --- /dev/null +++ b/tests/cassettes/test_item_search/TestItemSearchQuery.test_query_json_syntax.yaml @@ -0,0 +1,208 @@ +interactions: +- request: + body: '{"limit": 1, "bbox": [-73.21, 43.99, -73.12, 44.05], "query": {"eo:cloud_cover": + {"gte": 0, "lte": 1}}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '103' + Content-Type: + - application/json + User-Agent: + - python-requests/2.27.1 + method: POST + uri: https://planetarycomputer.microsoft.com/api/stac/v1/search + response: + body: + string: !!binary | + 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 + headers: + Content-Encoding: + - gzip + Content-Length: + - '3523' + Content-Type: + - application/geo+json + Date: + - Fri, 03 Jun 2022 16:36:02 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Accept-Encoding + X-Azure-Ref: + - 0WTiaYgAAAAAMaZ2ggs6HR4ti4CNcjpnLTU5aMjIxMDYwNjEzMDMxADkyN2FiZmE2LTE5ZjYtNGFmMS1hMDlkLWM5NTlkOWExZTY0NA== + X-Cache: + - CONFIG_NOCACHE + status: + code: 200 + message: OK +- request: + body: '{"limit": 1, "bbox": [-73.21, 43.99, -73.12, 44.05], "query": {"eo:cloud_cover": + {"gte": 0, "lte": 1}}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '103' + Content-Type: + - application/json + User-Agent: + - python-requests/2.27.1 + method: POST + uri: https://planetarycomputer.microsoft.com/api/stac/v1/search + response: + body: + string: !!binary | + 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 + headers: + Content-Encoding: + - gzip + Content-Length: + - '3523' + Content-Type: + - application/geo+json + Date: + - Fri, 03 Jun 2022 16:36:57 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + Vary: + - Accept-Encoding + X-Azure-Ref: + - 0qDiaYgAAAACuMFSv+tPOTZWaGVhgl17dTU5aMjIxMDYwNjExMDIxADkyN2FiZmE2LTE5ZjYtNGFmMS1hMDlkLWM5NTlkOWExZTY0NA== + X-Cache: + - CONFIG_NOCACHE + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_item_search.py b/tests/test_item_search.py index f26b5b18..6633e0b1 100644 --- a/tests/test_item_search.py +++ b/tests/test_item_search.py @@ -639,3 +639,43 @@ def test_query_shortcut_syntax(self) -> None: assert len(items1) == 1 assert len(items2) == 1 assert items1[0].id == items2[0].id + + @pytest.mark.vcr + def test_query_json_syntax(self) -> None: + + # with a list of json strs (format of CLI argument to ItemSearch) + search = ItemSearch( + url=SEARCH_URL, + bbox=(-73.21, 43.99, -73.12, 44.05), + query=['{"eo:cloud_cover": { "gte": 0, "lte": 1 }}'], + max_items=1, + ) + item1 = list(search.items())[0] + assert item1.properties["eo:cloud_cover"] <= 1 + + # with a single dict + search = ItemSearch( + url=SEARCH_URL, + bbox=(-73.21, 43.99, -73.12, 44.05), + query={"eo:cloud_cover": {"gte": 0, "lte": 1}}, + max_items=1, + ) + item2 = list(search.items())[0] + assert item2.properties["eo:cloud_cover"] <= 1 + + assert item1.id == item2.id + + +def test_query_json_syntax() -> None: + 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": {"gte": 0, "lte": 1}}) == { + "eo:cloud_cover": {"gte": 0, "lte": 1} + } + assert ItemSearch._format_query(["eo:cloud_cover<=1"]) == { + "eo:cloud_cover": {"lte": "1"} + } + assert ItemSearch._format_query(["eo:cloud_cover<=1", "eo:cloud_cover>0"]) == { + "eo:cloud_cover": {"lte": "1", "gt": "0"} + } From e57389d22ad63795e04ed8b91ef6265286cf960d Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Fri, 3 Jun 2022 12:54:00 -0400 Subject: [PATCH 2/6] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9329d47c..8d3972bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed -- Search query parameter now has correct typing and handles Query Extension JSON format. []() +- 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) From ff76ec474fe1635f798d202acc8002a4f5503c25 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Fri, 3 Jun 2022 14:20:31 -0400 Subject: [PATCH 3/6] fix incorrect conformance class for collections, and add checking for all ItemSearch features --- CHANGELOG.md | 6 +++- pystac_client/client.py | 22 ++++++++---- pystac_client/collection_client.py | 5 +-- pystac_client/conformance.py | 8 ++--- pystac_client/item_search.py | 48 ++++++++++++++----------- tests/data/planetary-computer-root.json | 1 + tests/test_client.py | 2 +- tests/test_item_search.py | 13 +++---- 8 files changed, 63 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3972bf..23ba56b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,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. []() ## [v0.3.5] - 2022-05-26 diff --git a/pystac_client/client.py b/pystac_client/client.py index dd1c0dde..dea38305 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -29,7 +29,7 @@ class Client(pystac.Catalog): such as searching items (e.g., /search endpoint). """ - def __repr__(self): + def __repr__(self) -> str: return "".format(self.id) @classmethod @@ -93,6 +93,16 @@ def from_file( return cat + def _supports_collections(self) -> bool: + return self._conforms_to(ConformanceClasses.COLLECTIONS) or self._conforms_to( + ConformanceClasses.FEATURES + ) + + def _conforms_to(self, conformance_class: ConformanceClasses) -> bool: + if isinstance(self._stac_io, StacApiIO): + return self._stac_io.conforms_to(conformance_class) + return True + @lru_cache() def get_collection(self, collection_id: str) -> CollectionClient: """Get a single collection from this Catalog/API @@ -103,7 +113,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 @@ -123,7 +133,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: @@ -140,7 +150,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: @@ -155,7 +165,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() @@ -191,7 +201,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}"' 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..066f8946 100644 --- a/pystac_client/conformance.py +++ b/pystac_client/conformance.py @@ -15,10 +15,10 @@ class ConformanceClasses(Enum): 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')}" + 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')}" 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..9be48e9e 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} + def _assert_conforms_to(self, conformance_class: ConformanceClasses) -> None: + if isinstance(self._stac_io, StacApiIO): + self._stac_io.assert_conforms_to(conformance_class) + 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 b06b0f6c..30d00f17 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -294,7 +294,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"} } From 66c3e3a79d6e4c653d6916c0684f968e0c57b47d Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Fri, 3 Jun 2022 14:49:44 -0400 Subject: [PATCH 4/6] cleanup --- CHANGELOG.md | 2 +- pystac_client/client.py | 13 +++++++++---- pystac_client/conformance.py | 10 ++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ba56b4..58dd79e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - 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. []() + (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 dea38305..d2851bee 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -36,7 +36,7 @@ def __repr__(self) -> str: 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": @@ -48,6 +48,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 @@ -76,8 +78,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 @@ -213,7 +215,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/conformance.py b/pystac_client/conformance.py index 066f8946..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 = 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')}" CONFORMANCE_URIS = {c.name: c.value for c in ConformanceClasses} From 0c6c061a50422949ed8032d39a49706363b56d30 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 6 Jun 2022 12:31:39 -0400 Subject: [PATCH 5/6] fix all untyped defs and literal dict default parameters --- pystac_client/item_search.py | 6 ++++-- pystac_client/stac_api_io.py | 24 +++++++++++++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/pystac_client/item_search.py b/pystac_client/item_search.py index 9be48e9e..525a6400 100644 --- a/pystac_client/item_search.py +++ b/pystac_client/item_search.py @@ -364,12 +364,14 @@ def _format_bbox(value: Optional[BBoxLike]) -> Optional[BBox]: @staticmethod def _format_datetime(value: Optional[DatetimeLike]) -> Optional[Datetime]: - def _to_utc_isoformat(dt): + def _to_utc_isoformat(dt: datetime_) -> str: dt = dt.astimezone(timezone.utc) dt = dt.replace(tzinfo=None) return dt.isoformat("T") + "Z" - def _to_isoformat_range(component: DatetimeOrTimestamp): + def _to_isoformat_range( + component: DatetimeOrTimestamp, + ) -> Tuple[Optional[str], Optional[str]]: """Converts a single DatetimeOrTimestamp into one or two Datetimes. This is required to expand a single value like "2017" out to the whole diff --git a/pystac_client/stac_api_io.py b/pystac_client/stac_api_io.py index 7c577bc8..bd61843c 100644 --- a/pystac_client/stac_api_io.py +++ b/pystac_client/stac_api_io.py @@ -58,7 +58,7 @@ def read_text( self, source: Union[str, Link], *args: Any, - parameters: Optional[dict] = {}, + parameters: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> str: """Read text from the given URI. @@ -105,8 +105,8 @@ def request( self, href: str, method: Optional[str] = "GET", - headers: Optional[dict] = {}, - parameters: Optional[dict] = {}, + headers: Optional[Dict[str, str]] = None, + parameters: Optional[Dict[str, Any]] = None, ) -> str: """Makes a request to an http endpoint @@ -114,10 +114,10 @@ def request( href (str): The request URL method (Optional[str], optional): The http method to use, 'GET' or 'POST'. Defaults to 'GET'. - headers (Optional[dict], optional): Additional headers to include in - request. Defaults to {}. - parameters (Optional[dict], optional): parameters to send with request. - Defaults to {}. + headers (Optional[Dict[str, str]], optional): Additional headers to include + in request. Defaults to None. + parameters (Optional[Dict[str, Any]], optional): parameters to send with + request. Defaults to None. Raises: APIError: raised if the server returns an error response @@ -128,7 +128,7 @@ def request( if method == "POST": request = Request(method=method, url=href, headers=headers, json=parameters) else: - params = deepcopy(parameters) + params = deepcopy(parameters) or {} if "intersects" in params: params["intersects"] = json.dumps(params["intersects"]) request = Request(method=method, url=href, headers=headers, params=params) @@ -188,14 +188,14 @@ def stac_object_from_dict( d = migrate_to_latest(d, info) if info.object_type == pystac.STACObjectType.CATALOG: - result = pystac_client.Client.from_dict( + result = pystac_client.client.Client.from_dict( d, href=href, root=root, migrate=False, preserve_dict=preserve_dict ) result._stac_io = self return result if info.object_type == pystac.STACObjectType.COLLECTION: - return pystac_client.CollectionClient.from_dict( + return pystac_client.collection_client.CollectionClient.from_dict( d, href=href, root=root, migrate=False, preserve_dict=preserve_dict ) @@ -206,7 +206,9 @@ def stac_object_from_dict( raise ValueError(f"Unknown STAC object type {info.object_type}") - def get_pages(self, url, method="GET", parameters={}) -> Iterator[Dict]: + def get_pages( + self, url: str, method: str = "GET", parameters: Optional[Dict[str, str]] = None + ) -> Iterator[Dict[str, Any]]: """Iterator that yields dictionaries for each page at a STAC paging endpoint, e.g., /collections, /search From d7866d965af9dd438d9736c23ca509f10a21db24 Mon Sep 17 00:00:00 2001 From: Phil Varner Date: Mon, 6 Jun 2022 13:49:14 -0400 Subject: [PATCH 6/6] typing fixes --- .github/pull_request_template.md | 4 +- pystac_client/client.py | 69 ++++++++++++++++++------------ pystac_client/collection_client.py | 4 +- pystac_client/item_search.py | 40 +++++++++-------- pystac_client/stac_api_io.py | 13 +++--- tests/test_client.py | 4 +- 6 files changed, 80 insertions(+), 54 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 35f1c331..5cd7316c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,6 @@ -**Related Issue(s):** # +**Related Issue(s):** + +- # **Description:** diff --git a/pystac_client/client.py b/pystac_client/client.py index d2851bee..4cd4b7aa 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -3,6 +3,7 @@ import pystac import pystac.validation +from pystac import Collection from pystac_client.collection_client import CollectionClient from pystac_client.conformance import ConformanceClasses @@ -59,25 +60,30 @@ def open( Return: catalog : A :class:`Client` instance for this Catalog/API """ - cat = cls.from_file(url, headers=headers, parameters=parameters) - search_link = cat.get_search_link() + client: Client = cls.from_file(url, headers=headers, parameters=parameters) + search_link = client.get_search_link() # if there is a search link, but no conformsTo advertised, ignore # conformance entirely # NOTE: this behavior to be deprecated as implementations become conformant - if ignore_conformance or ( - "conformsTo" not in cat.extra_fields.keys() - and search_link - and search_link.href - and len(search_link.href) > 0 + if client._stac_io and ( + ignore_conformance + or ( + client + and "conformsTo" not in client.extra_fields.keys() + and search_link + and search_link.href + and len(search_link.href) > 0 + ) ): - cat._stac_io.set_conformance(None) - return cat + client._stac_io.set_conformance(None) # type: ignore + + return client @classmethod - def from_file( + def from_file( # type: ignore cls, href: str, - stac_io: Optional[pystac.StacIO] = None, + stac_io: Optional[StacApiIO] = None, headers: Optional[Dict[str, str]] = None, parameters: Optional[Dict[str, Any]] = None, ) -> "Client": @@ -89,11 +95,13 @@ def from_file( if stac_io is None: stac_io = StacApiIO(headers=headers, parameters=parameters) - cat = super().from_file(href, stac_io) + client: Client = super().from_file(href, stac_io) # type: ignore - cat._stac_io._conformance = cat.extra_fields.get("conformsTo", []) + client._stac_io._conformance = client.extra_fields.get( # type: ignore + "conformsTo", [] + ) - return cat + return client def _supports_collections(self) -> bool: return self._conforms_to(ConformanceClasses.COLLECTIONS) or self._conforms_to( @@ -101,12 +109,10 @@ def _supports_collections(self) -> bool: ) def _conforms_to(self, conformance_class: ConformanceClasses) -> bool: - if isinstance(self._stac_io, StacApiIO): - return self._stac_io.conforms_to(conformance_class) - return True + return self._stac_io.conforms_to(conformance_class) # type: ignore @lru_cache() - def get_collection(self, collection_id: str) -> CollectionClient: + def get_collection(self, collection_id: str) -> Optional[Collection]: """Get a single collection from this Catalog/API Args: @@ -115,7 +121,7 @@ def get_collection(self, collection_id: str) -> CollectionClient: Returns: CollectionClient: A STAC Collection """ - if self._supports_collections(): + if self._supports_collections() and self._stac_io: url = f"{self.get_self_href()}/collections/{collection_id}" collection = CollectionClient.from_dict( self._stac_io.read_json(url), root=self @@ -126,7 +132,9 @@ def get_collection(self, collection_id: str) -> CollectionClient: if col.id == collection_id: return col - def get_collections(self) -> Iterable[CollectionClient]: + return None + + def get_collections(self) -> Iterable[Collection]: """Get Collections in this Catalog Gets the collections from the /collections endpoint if supported, @@ -135,9 +143,9 @@ def get_collections(self) -> Iterable[CollectionClient]: Return: Iterable[CollectionClient]: Iterator through Collections in Catalog/API """ - if self._supports_collections(): - url = self.get_self_href() + "/collections" - for page in self._stac_io.get_pages(url): + if self._supports_collections() and self.get_self_href() is not None: + url = f"{self.get_self_href()}/collections" + for page in self._stac_io.get_pages(url): # type: ignore if "collections" not in page: raise APIError("Invalid response from /collections") for col in page["collections"]: @@ -209,14 +217,21 @@ def search(self, **kwargs: Any) -> ItemSearch: f'does not conform to "{ConformanceClasses.ITEM_SEARCH}"' ) search_link = self.get_search_link() - if search_link is None: + if search_link: + if isinstance(search_link.target, str): + search_href = search_link.target + else: + raise NotImplementedError( + "Link with rel=search was an object rather than a URI" + ) + else: raise NotImplementedError( - 'No link with "rel" type of "search" could be found in this catalog' + "No link with rel=search could be found in this catalog" ) return ItemSearch( - search_link.target, - stac_io=self._stac_io, + url=search_href, + stac_io=self._stac_io, # type: ignore client=self, **kwargs, ) diff --git a/pystac_client/collection_client.py b/pystac_client/collection_client.py index 727062e8..a66fb4c5 100644 --- a/pystac_client/collection_client.py +++ b/pystac_client/collection_client.py @@ -29,7 +29,9 @@ def get_items(self) -> Iterable["Item_Type"]: link = self.get_single_link("items") root = self.get_root() if link is not None and root is not None: - search = ItemSearch(link.href, method="GET", stac_io=root._stac_io) + search = ItemSearch( + url=link.href, method="GET", stac_io=root._stac_io + ) # type: ignore yield from search.items() else: yield from super().get_items() diff --git a/pystac_client/item_search.py b/pystac_client/item_search.py index 525a6400..1198cc81 100644 --- a/pystac_client/item_search.py +++ b/pystac_client/item_search.py @@ -12,13 +12,12 @@ from dateutil.relativedelta import relativedelta from dateutil.tz import tzutc from pystac import Collection, Item, ItemCollection -from pystac.stac_io import StacIO from pystac_client.conformance import ConformanceClasses from pystac_client.stac_api_io import StacApiIO if TYPE_CHECKING: - from pystac_client.client import Client + from pystac_client import client DATETIME_REGEX = re.compile( r"(?P\d{4})(\-(?P\d{2})(\-(?P\d{2})" @@ -219,8 +218,8 @@ def __init__( *, method: Optional[str] = "POST", max_items: Optional[int] = DEFAULT_LIMIT_AND_MAX_ITEMS, - stac_io: Optional[StacIO] = None, - client: Optional["Client"] = None, + stac_io: Optional[StacApiIO] = None, + client: Optional["client.Client"] = None, limit: Optional[int] = DEFAULT_LIMIT_AND_MAX_ITEMS, ids: Optional[IDsLike] = None, collections: Optional[CollectionsLike] = None, @@ -293,7 +292,7 @@ def get_parameters(self) -> Dict[str, Any]: else: raise Exception(f"Unsupported method {self.method}") - def _format_query(self, value: QueryLike) -> Optional[Dict[str, Any]]: + def _format_query(self, value: Optional[QueryLike]) -> Optional[Dict[str, Any]]: if value is None: return None @@ -367,7 +366,7 @@ def _format_datetime(value: Optional[DatetimeLike]) -> Optional[Datetime]: def _to_utc_isoformat(dt: datetime_) -> str: dt = dt.astimezone(timezone.utc) dt = dt.replace(tzinfo=None) - return dt.isoformat("T") + "Z" + return f'{dt.isoformat("T")}Z' def _to_isoformat_range( component: DatetimeOrTimestamp, @@ -454,20 +453,20 @@ def _to_isoformat_range( @staticmethod def _format_collections(value: Optional[CollectionsLike]) -> Optional[Collections]: - def _format(c: Any) -> Any: + def _format(c: Any) -> Collections: if isinstance(c, str): - return c + return (c,) if isinstance(c, Iterable): - return tuple(map(_format, c)) + return tuple(map(lambda x: _format(x)[0], c)) - return c.id + return (c.id,) if value is None: return None if isinstance(value, str): - return tuple(map(_format, value.split(","))) + return tuple(map(lambda x: _format(x)[0], value.split(","))) if isinstance(value, Collection): - return (_format(value),) + return _format(value) return _format(value) @@ -492,7 +491,7 @@ def _format_sortby(self, value: Optional[SortbyLike]) -> Optional[Sortby]: 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(str(v)) for v in value] elif value and isinstance(value[0], dict): return value @@ -561,9 +560,9 @@ def _format_intersects(value: Optional[IntersectsLike]) -> Optional[Intersects]: if isinstance(value, dict): return deepcopy(value) if isinstance(value, str): - return json.loads(value) + return dict(json.loads(value)) if hasattr(value, "__geo_interface__"): - return deepcopy(getattr(value, "__geo_interface__")) + return dict(deepcopy(getattr(value, "__geo_interface__"))) raise Exception( "intersects must be of type None, str, dict, or an object that " "implements __geo_interface__" @@ -612,10 +611,13 @@ def item_collections(self) -> Iterator[ItemCollection]: ItemCollection : a group of Items matching the search criteria within an ItemCollection """ - for page in self._stac_io.get_pages( - self.url, self.method, self.get_parameters() - ): - yield ItemCollection.from_dict(page, preserve_dict=False, root=self.client) + if isinstance(self._stac_io, StacApiIO): + for page in self._stac_io.get_pages( + self.url, self.method, self.get_parameters() + ): + yield ItemCollection.from_dict( + page, preserve_dict=False, root=self.client + ) def get_items(self) -> Iterator[Item]: """DEPRECATED. Use :meth:`ItemSearch.items` instead. diff --git a/pystac_client/stac_api_io.py b/pystac_client/stac_api_io.py index bd61843c..822a4825 100644 --- a/pystac_client/stac_api_io.py +++ b/pystac_client/stac_api_io.py @@ -104,7 +104,7 @@ def read_text( def request( self, href: str, - method: Optional[str] = "GET", + method: Optional[str] = None, headers: Optional[Dict[str, str]] = None, parameters: Optional[Dict[str, Any]] = None, ) -> str: @@ -113,7 +113,7 @@ def request( Args: href (str): The request URL method (Optional[str], optional): The http method to use, 'GET' or 'POST'. - Defaults to 'GET'. + Defaults to None, which will result in 'GET' being used. headers (Optional[Dict[str, str]], optional): Additional headers to include in request. Defaults to None. parameters (Optional[Dict[str, Any]], optional): parameters to send with @@ -131,7 +131,7 @@ def request( params = deepcopy(parameters) or {} if "intersects" in params: params["intersects"] = json.dumps(params["intersects"]) - request = Request(method=method, url=href, headers=headers, params=params) + request = Request(method="GET", url=href, headers=headers, params=params) try: prepped = self.session.prepare_request(request) msg = f"{prepped.method} {prepped.url} Headers: {prepped.headers}" @@ -207,7 +207,10 @@ def stac_object_from_dict( raise ValueError(f"Unknown STAC object type {info.object_type}") def get_pages( - self, url: str, method: str = "GET", parameters: Optional[Dict[str, str]] = None + self, + url: str, + method: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, ) -> Iterator[Dict[str, Any]]: """Iterator that yields dictionaries for each page at a STAC paging endpoint, e.g., /collections, /search @@ -275,5 +278,5 @@ def conforms_to(self, conformance_class: ConformanceClasses) -> bool: return True def set_conformance(self, conformance: Optional[List[str]]) -> None: - """Sets (or clears) the conformances for this StacIO.""" + """Sets (or clears) the conformance classes for this StacIO.""" self._conformance = conformance diff --git a/tests/test_client.py b/tests/test_client.py index 30d00f17..80358648 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -349,7 +349,9 @@ def test_no_search_link(self, api): with pytest.raises(NotImplementedError) as excinfo: api.search(limit=10, max_items=10, collections="naip") - assert 'No link with "rel" type of "search"' in str(excinfo.value) + assert "No link with rel=search could be found in this catalog" in str( + excinfo.value + ) def test_no_conforms_to(self) -> None: with open(str(TEST_DATA / "planetary-computer-root.json")) as f: