diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc3c576..c2ef76df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Timeout option added to `Client.open` [#463](https://github.com/stac-utils/pystac-client/pull/463) + ### Changed - Switched to Ruff from isort/flake8 [#457](https://github.com/stac-utils/pystac-client/pull/457) diff --git a/pystac_client/client.py b/pystac_client/client.py index a9b7f743..65d84250 100644 --- a/pystac_client/client.py +++ b/pystac_client/client.py @@ -1,5 +1,14 @@ from functools import lru_cache -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterator, + List, + Optional, + Union, +) import pystac import pystac.utils @@ -27,7 +36,7 @@ SortbyLike, ) from pystac_client.mixins import QueryablesMixin -from pystac_client.stac_api_io import StacApiIO +from pystac_client.stac_api_io import StacApiIO, Timeout if TYPE_CHECKING: from pystac.item import Item as Item_Type @@ -88,6 +97,7 @@ def open( modifier: Optional[Callable[[Modifiable], None]] = None, request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None, stac_io: Optional[StacApiIO] = None, + timeout: Timeout = None, ) -> "Client": """Opens a STAC Catalog or API This function will read the root catalog of a STAC Catalog or API @@ -137,6 +147,9 @@ def open( stac_io: A `StacApiIO` object to use for I/O requests. Generally, leave this to the default. However in cases where customized I/O processing is required, a custom instance can be provided here. + timeout: Optional float or (float, float) tuple following the semantics + defined by `Requests + `__. Return: catalog : A :class:`Client` instance for this Catalog/API @@ -148,6 +161,7 @@ def open( modifier=modifier, request_modifier=request_modifier, stac_io=stac_io, + timeout=timeout, ) search_link = client.get_search_link() # if there is a search link, but no conformsTo advertised, ignore @@ -176,6 +190,7 @@ def from_file( # type: ignore parameters: Optional[Dict[str, Any]] = None, modifier: Optional[Callable[[Modifiable], None]] = None, request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None, + timeout: Timeout = None, ) -> "Client": """Open a STAC Catalog/API @@ -187,12 +202,14 @@ def from_file( # type: ignore headers=headers, parameters=parameters, request_modifier=request_modifier, + timeout=timeout, ) else: stac_io.update( headers=headers, parameters=parameters, request_modifier=request_modifier, + timeout=timeout, ) client: Client = super().from_file(href, stac_io) diff --git a/pystac_client/stac_api_io.py b/pystac_client/stac_api_io.py index 8c358588..69f2e1e7 100644 --- a/pystac_client/stac_api_io.py +++ b/pystac_client/stac_api_io.py @@ -2,7 +2,18 @@ import logging import re from copy import deepcopy -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterator, + List, + Optional, + Tuple, + Union, +) +from typing_extensions import TypeAlias from urllib.parse import urlparse import pystac @@ -28,6 +39,9 @@ logger = logging.getLogger(__name__) +Timeout: TypeAlias = Optional[Union[float, Tuple[float, float], Tuple[float, None]]] + + class StacApiIO(DefaultStacIO): def __init__( self, @@ -35,6 +49,7 @@ def __init__( conformance: Optional[List[str]] = None, parameters: Optional[Dict[str, Any]] = None, request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None, + timeout: Timeout = None, ): """Initialize class for API IO @@ -48,6 +63,9 @@ def __init__( objects before they are sent. If provided, the callable receives a `request.Request` and must either modify the object directly or return a new / modified request instance. + timeout: Optional float or (float, float) tuple following the semantics + defined by `Requests + `__. Return: StacApiIO : StacApiIO instance @@ -55,6 +73,7 @@ def __init__( # TODO - this should super() to parent class self.session = Session() self._conformance = conformance + self.timeout = timeout self.update( headers=headers, parameters=parameters, request_modifier=request_modifier ) @@ -64,6 +83,7 @@ def update( headers: Optional[Dict[str, str]] = None, parameters: Optional[Dict[str, Any]] = None, request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None, + timeout: Timeout = None, ) -> None: """Updates this StacApi's headers, parameters, and/or request_modifer. @@ -75,10 +95,14 @@ def update( objects before they are sent. If provided, the callable receives a `request.Request` and must either modify the object directly or return a new / modified request instance. + timeout: Optional float or (float, float) tuple following the semantics + defined by `Requests + `__. """ self.session.headers.update(headers or {}) self.session.params.update(parameters or {}) # type: ignore self._req_modifier = request_modifier + self.timeout = timeout def read_text(self, source: pystac.link.HREF, *args: Any, **kwargs: Any) -> str: """Read text from the given URI. @@ -161,9 +185,12 @@ def request( msg = f"{prepped.method} {prepped.url} Headers: {prepped.headers}" if method == "POST": msg += f" Payload: {json.dumps(request.json)}" + if self.timeout is not None: + msg += f" Timeout: {self.timeout}" logger.debug(msg) - resp = self.session.send(prepped) + resp = self.session.send(prepped, timeout=self.timeout) except Exception as err: + logger.debug(err) raise APIError(str(err)) if resp.status_code != 200: raise APIError.from_response(resp) diff --git a/tests/cassettes/test_stac_api_io/TestSTAC_IOOverride.test_timeout_smoke_test.yaml b/tests/cassettes/test_stac_api_io/TestSTAC_IOOverride.test_timeout_smoke_test.yaml new file mode 100644 index 00000000..17e6b10c --- /dev/null +++ b/tests/cassettes/test_stac_api_io/TestSTAC_IOOverride.test_timeout_smoke_test.yaml @@ -0,0 +1,97 @@ +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: + - Fri, 21 Apr 2023 13:32:05 GMT + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Cache: + - CONFIG_NOCACHE + content-encoding: + - gzip + vary: + - Accept-Encoding + x-azure-ref: + - 20230421T133204Z-s8t62zhfm931t3a14qcgbhkkrg00000005u0000000009bmb + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_stac_api_io.py b/tests/test_stac_api_io.py index 8eb56ce9..1963e6f6 100644 --- a/tests/test_stac_api_io.py +++ b/tests/test_stac_api_io.py @@ -279,3 +279,11 @@ def test_stop_on_first_empty_page( stac_api_io = StacApiIO() pages = list(stac_api_io.get_pages(url)) assert len(pages) == 0 + + @pytest.mark.vcr + def test_timeout_smoke_test(self) -> None: + # Testing timeout behavior is hard, so we just have a simple smoke test to make + # sure that providing a timeout doesn't break anything. + stac_api_io = StacApiIO(timeout=42) + response = stac_api_io.read_text(STAC_URLS["PLANETARY-COMPUTER"]) + assert isinstance(response, str)