Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timeout option to Client #463

Merged
merged 10 commits into from
Apr 25, 2023
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 19 additions & 2 deletions pystac_client/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
<https://requests.readthedocs.io/en/latest/api/#main-interface>`__.

Return:
catalog : A :class:`Client` instance for this Catalog/API
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -187,12 +202,14 @@ def from_file( # type: ignore
headers=headers,
parameters=parameters,
request_modifier=request_modifier,
timeout=timeout,
)
else:
stac_io.update(
gadomski marked this conversation as resolved.
Show resolved Hide resolved
headers=headers,
parameters=parameters,
request_modifier=request_modifier,
timeout=timeout,
)

client: Client = super().from_file(href, stac_io)
Expand Down
31 changes: 29 additions & 2 deletions pystac_client/stac_api_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,13 +39,17 @@
logger = logging.getLogger(__name__)


Timeout: TypeAlias = Optional[Union[float, Tuple[float, float], Tuple[float, None]]]


class StacApiIO(DefaultStacIO):
def __init__(
self,
headers: Optional[Dict[str, str]] = None,
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

Expand All @@ -48,13 +63,17 @@ 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
<https://requests.readthedocs.io/en/latest/api/#main-interface>`__.

Return:
StacApiIO : StacApiIO instance
"""
# 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
)
Expand All @@ -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.

Expand All @@ -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
<https://requests.readthedocs.io/en/latest/api/#main-interface>`__.
"""
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.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions tests/test_stac_api_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)