Skip to content

Commit f98f670

Browse files
pjbullMchristosChris Marais
authored
Live tests for #484 (#505)
* #484 Add GSClient timeout support (#485) * add timeout support * no implicit optional * add optional retry * black * try this * type annotations --------- Co-authored-by: Chris Marais <[email protected]> * add changelog and tests --------- Co-authored-by: chris <[email protected]> Co-authored-by: Chris Marais <[email protected]>
1 parent e2e8035 commit f98f670

File tree

4 files changed

+79
-10
lines changed

4 files changed

+79
-10
lines changed

HISTORY.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Added support for `timeout` and `retry` kwargs for `GSClient`. (Issue [#484](https://github.com/drivendataorg/cloudpathlib/issues/484), PR [#485](https://github.com/drivendataorg/cloudpathlib/pull/485), thanks @Mchristos)
56
- Fixed `CloudPath(...) / other` to correctly attempt to fall back on `other`'s `__rtruediv__` implementation, in order to support classes that explicitly support the `/` with a `CloudPath` instance. Previously, this would always raise a `TypeError` if `other` were not a `str` or `PurePosixPath`. (PR [#479](https://github.com/drivendataorg/cloudpathlib/pull/479))
67
- Add `md5` property to `GSPath`, updated LocalGSPath to include `md5` property, updated mock_gs.MockBlob to include `md5_hash` property.
78
- Fixed an uncaught exception on Azure Gen2 storage accounts with HNS enabled when used with `DefaultAzureCredential`. (Issue [#486](https://github.com/drivendataorg/cloudpathlib/issues/486))

cloudpathlib/gs/gsclient.py

+15-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
try:
1414
if TYPE_CHECKING:
1515
from google.auth.credentials import Credentials
16+
from google.api_core.retry import Retry
1617

1718
from google.auth.exceptions import DefaultCredentialsError
1819
from google.cloud.storage import Client as StorageClient
@@ -45,6 +46,8 @@ def __init__(
4546
local_cache_dir: Optional[Union[str, os.PathLike]] = None,
4647
content_type_method: Optional[Callable] = mimetypes.guess_type,
4748
download_chunks_concurrently_kwargs: Optional[Dict[str, Any]] = None,
49+
timeout: Optional[float] = None,
50+
retry: Optional["Retry"] = None,
4851
):
4952
"""Class constructor. Sets up a [`Storage
5053
Client`](https://googleapis.dev/python/storage/latest/client.html).
@@ -85,6 +88,8 @@ def __init__(
8588
download_chunks_concurrently_kwargs (Optional[Dict[str, Any]]): Keyword arguments to pass to
8689
[`download_chunks_concurrently`](https://cloud.google.com/python/docs/reference/storage/latest/google.cloud.storage.transfer_manager#google_cloud_storage_transfer_manager_download_chunks_concurrently)
8790
for sliced parallel downloads; Only available in `google-cloud-storage` version 2.7.0 or later, otherwise ignored and a warning is emitted.
91+
timeout (Optional[float]): Cloud Storage [timeout value](https://cloud.google.com/python/docs/reference/storage/1.39.0/retry_timeout)
92+
retry (Optional[google.api_core.retry.Retry]): Cloud Storage [retry configuration](https://cloud.google.com/python/docs/reference/storage/1.39.0/retry_timeout#configuring-retries)
8893
"""
8994
if application_credentials is None:
9095
application_credentials = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
@@ -102,6 +107,13 @@ def __init__(
102107
self.client = StorageClient.create_anonymous_client()
103108

104109
self.download_chunks_concurrently_kwargs = download_chunks_concurrently_kwargs
110+
self.blob_kwargs: dict[str, Any] = {}
111+
if timeout is not None:
112+
self.timeout: float = timeout
113+
self.blob_kwargs["timeout"] = self.timeout
114+
if retry is not None:
115+
self.retry: Retry = retry
116+
self.blob_kwargs["retry"] = self.retry
105117

106118
super().__init__(
107119
local_cache_dir=local_cache_dir,
@@ -129,7 +141,6 @@ def _download_file(self, cloud_path: GSPath, local_path: Union[str, os.PathLike]
129141
blob = bucket.get_blob(cloud_path.blob)
130142

131143
local_path = Path(local_path)
132-
133144
if transfer_manager is not None and self.download_chunks_concurrently_kwargs is not None:
134145
transfer_manager.download_chunks_concurrently(
135146
blob, local_path, **self.download_chunks_concurrently_kwargs
@@ -140,7 +151,7 @@ def _download_file(self, cloud_path: GSPath, local_path: Union[str, os.PathLike]
140151
"Ignoring `download_chunks_concurrently_kwargs` for version of google-cloud-storage that does not support them (<2.7.0)."
141152
)
142153

143-
blob.download_to_filename(local_path)
154+
blob.download_to_filename(local_path, **self.blob_kwargs)
144155

145156
return local_path
146157

@@ -247,7 +258,7 @@ def _move_file(self, src: GSPath, dst: GSPath, remove_src: bool = True) -> GSPat
247258
dst_bucket = self.client.bucket(dst.bucket)
248259

249260
src_blob = src_bucket.get_blob(src.blob)
250-
src_bucket.copy_blob(src_blob, dst_bucket, dst.blob)
261+
src_bucket.copy_blob(src_blob, dst_bucket, dst.blob, **self.blob_kwargs)
251262

252263
if remove_src:
253264
src_blob.delete()
@@ -280,7 +291,7 @@ def _upload_file(self, local_path: Union[str, os.PathLike], cloud_path: GSPath)
280291
content_type, _ = self.content_type_method(str(local_path))
281292
extra_args["content_type"] = content_type
282293

283-
blob.upload_from_filename(str(local_path), **extra_args)
294+
blob.upload_from_filename(str(local_path), **extra_args, **self.blob_kwargs)
284295
return cloud_path
285296

286297
def _get_public_url(self, cloud_path: GSPath) -> str:

tests/mock_clients/mock_gs.py

+28-5
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,19 @@ def delete(self):
5757
path.unlink()
5858
delete_empty_parents_up_to_root(path=path, root=self.bucket)
5959

60-
def download_to_filename(self, filename):
60+
def download_to_filename(self, filename, timeout=None, retry=None):
61+
# if timeout is not None, assume that the test wants a timeout and throw it
62+
if timeout is not None:
63+
raise TimeoutError("Download timed out")
64+
65+
# indicate that retry object made it through to the GS lib
66+
if retry is not None:
67+
retry.mocked_retries = 1
68+
6169
from_path = self.bucket / self.name
62-
to_path = Path(filename)
6370

71+
to_path = Path(filename)
6472
to_path.parent.mkdir(exist_ok=True, parents=True)
65-
6673
to_path.write_bytes(from_path.read_bytes())
6774

6875
def patch(self):
@@ -84,7 +91,15 @@ def reload(
8491
):
8592
pass
8693

87-
def upload_from_filename(self, filename, content_type=None):
94+
def upload_from_filename(self, filename, content_type=None, timeout=None, retry=None):
95+
# if timeout is not None, assume that the test wants a timeout and throw it
96+
if timeout is not None:
97+
raise TimeoutError("Upload timed out")
98+
99+
# indicate that retry object made it through to the GS lib
100+
if retry is not None:
101+
retry.mocked_retries = 1
102+
88103
data = Path(filename).read_bytes()
89104
path = self.bucket / self.name
90105
path.parent.mkdir(parents=True, exist_ok=True)
@@ -131,7 +146,15 @@ def __init__(self, name, bucket_name, client=None):
131146
def blob(self, blob):
132147
return MockBlob(self.name, blob, client=self.client)
133148

134-
def copy_blob(self, blob, destination_bucket, new_name):
149+
def copy_blob(self, blob, destination_bucket, new_name, timeout=None, retry=None):
150+
# if timeout is not None, assume that the test wants a timeout and throw it
151+
if timeout is not None:
152+
raise TimeoutError("Copy timed out")
153+
154+
# indicate that retry object made it through to the GS lib
155+
if retry is not None:
156+
retry.mocked_retries = 1
157+
135158
data = (self.name / blob.name).read_bytes()
136159
dst = destination_bucket.name / new_name
137160
dst.parent.mkdir(exist_ok=True, parents=True)

tests/test_gs_specific.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from urllib.parse import urlparse, parse_qs
2+
3+
from google.api_core import retry
4+
from google.api_core import exceptions
15
import pytest
26

3-
from urllib.parse import urlparse, parse_qs
47
from cloudpathlib import GSPath
58
from cloudpathlib.local import LocalGSPath
69

@@ -75,3 +78,34 @@ def _calculate_b64_wrapped_md5_hash(contents: str) -> str:
7578
p: GSPath = gs_rig.create_cloud_path("dir_0/file0_0.txt")
7679
p.write_text(contents)
7780
assert p.md5 == expected_hash
81+
82+
83+
def test_timeout_and_retry(gs_rig):
84+
custom_retry = retry.Retry(
85+
timeout=0.50,
86+
predicate=retry.if_exception_type(exceptions.ServerError),
87+
)
88+
89+
fast_timeout_client = gs_rig.client_class(timeout=0.00001, retry=custom_retry)
90+
91+
with pytest.raises(Exception) as exc_info:
92+
p = gs_rig.create_cloud_path("dir_0/file0_0.txt", client=fast_timeout_client)
93+
p.write_text("hello world " * 10000)
94+
95+
assert "timed out" in str(exc_info.value)
96+
97+
# can't force retries to happen in live cloud tests, so skip
98+
if not gs_rig.live_server:
99+
custom_retry = retry.Retry(
100+
initial=1.0,
101+
multiplier=1.0,
102+
timeout=15.0,
103+
predicate=retry.if_exception_type(exceptions.ServerError),
104+
)
105+
106+
p = gs_rig.create_cloud_path(
107+
"dir_0/file0_0.txt", client=gs_rig.client_class(retry=custom_retry)
108+
)
109+
p.write_text("hello world")
110+
111+
assert custom_retry.mocked_retries == 1

0 commit comments

Comments
 (0)