Skip to content

Commit d08e111

Browse files
snamberampagent
andcommitted
Restore GoogleStorageCache support for SDK bucket objects
Keep supplied Google bucket clients intact while retaining obstore for bucket-name strings. Cover custom credentials, transport, endpoints, requester-pays billing, groups, and error handling without adding a runtime SDK dependency. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd95-f057-731f-8eb9-cf195588c2cd
1 parent d2c6247 commit d08e111

5 files changed

Lines changed: 283 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `tilebox-workflows`: Restore `GoogleStorageCache` support for Google SDK bucket objects, preserving their
13+
configured client, credentials, and request settings. Bucket-name strings continue to use obstore.
14+
15+
## [0.63.0] - 2026-10-03
16+
1017
### Added
1118

1219
- Support Python 3.14 across all packages.
@@ -568,7 +575,8 @@ the first client that does not cache data (since it's already on the local file
568575
- Released under the [MIT](https://opensource.org/license/mit) license.
569576
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`
570577

571-
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.61.0...HEAD
578+
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.63.0...HEAD
579+
[0.63.0]: https://github.com/tilebox/tilebox-python/compare/v0.62.0...v0.63.0
572580
[0.61.0]: https://github.com/tilebox/tilebox-python/compare/v0.60.0...v0.61.0
573581
[0.60.0]: https://github.com/tilebox/tilebox-python/compare/v0.59.0...v0.60.0
574582
[0.59.0]: https://github.com/tilebox/tilebox-python/compare/v0.58.0...v0.59.0

‎tilebox-workflows/pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ geospatial = [
5050
[dependency-groups]
5151
dev = [
5252
"boto3-stubs[s3]>=1.40.2",
53+
"google-cloud-storage>=3.2.0",
54+
"grpcio-status>=1.78.0",
5355
"hypothesis>=6.112.1",
5456
"ipywidgets>=8.1.7",
5557
"odc-geo>=0.5",
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from collections.abc import Iterator
2+
from unittest.mock import MagicMock, patch
3+
from urllib.parse import parse_qs, urlsplit
4+
5+
import pytest
6+
from google.api_core.exceptions import Forbidden, NotFound, ServiceUnavailable
7+
from google.auth.credentials import AnonymousCredentials
8+
from google.cloud.storage import Blob, Bucket, Client
9+
from google.oauth2.credentials import Credentials
10+
from requests import Response
11+
12+
from tilebox.workflows.cache import GoogleStorageCache
13+
14+
15+
@pytest.fixture(params=[False, True], ids=["authenticated", "anonymous"])
16+
def google_bucket(request: pytest.FixtureRequest) -> Iterator[Bucket]:
17+
credentials = AnonymousCredentials() if request.param else Credentials(token="configured-token") # noqa: S106
18+
client = Client(
19+
project="configured-project",
20+
credentials=credentials,
21+
_http=MagicMock(),
22+
client_options={"api_endpoint": "https://custom.example.test"},
23+
)
24+
with (
25+
patch("google.auth.default", side_effect=AssertionError("Bucket credentials must not be replaced by ADC")),
26+
patch("obstore.store.GCSStore", side_effect=AssertionError("Bucket configuration must stay with its client")),
27+
):
28+
yield client.bucket("configured-bucket", user_project="billing-project")
29+
30+
31+
def test_google_bucket_read_preserves_client(google_bucket: Bucket) -> None:
32+
# Bucket reads and nested groups retain credentials, custom transport, endpoint, and requester-pays billing.
33+
credentials = google_bucket.client._credentials
34+
transport = google_bucket.client._http
35+
response = Response()
36+
response.status_code = 200
37+
response._content = b"configured client content"
38+
response._content_consumed = True
39+
response.raw = MagicMock(headers={})
40+
response.headers["content-length"] = str(len(response.content))
41+
transport.request.return_value = response
42+
root = GoogleStorageCache(google_bucket)
43+
cache = root.group("folder").group("nested")
44+
assert isinstance(cache, GoogleStorageCache)
45+
assert cache.bucket is root.bucket is google_bucket
46+
assert cache["file.txt"] == b"configured client content"
47+
assert google_bucket.client._credentials is credentials
48+
# Newer Google clients may also fetch bucket metadata in a background thread.
49+
downloads = [call for call in transport.request.call_args_list if call.args and "/download/" in call.args[1]]
50+
assert len(downloads) == 1
51+
method, url = downloads[0].args
52+
assert method == "GET"
53+
parsed = urlsplit(url)
54+
assert parsed.netloc == "custom.example.test"
55+
assert parsed.path.endswith("/configured-bucket/o/jobs%2Ffolder%2Fnested%2Ffile.txt")
56+
assert parse_qs(parsed.query)["userProject"] == ["billing-project"]
57+
58+
59+
@pytest.mark.parametrize("prefix", ["jobs", ""])
60+
def test_google_bucket_operations(google_bucket: Bucket, prefix: str) -> None:
61+
# Bucket caches preserve writes, membership, deletion, and delimiter-based listing for each group.
62+
cache = GoogleStorageCache(google_bucket, prefix).group("folder")
63+
path = f"{prefix}/folder" if prefix else "folder"
64+
with (
65+
patch.object(Blob, "upload_from_file", autospec=True) as upload,
66+
patch.object(Blob, "exists", autospec=True, return_value=True) as exists,
67+
patch.object(Blob, "delete", autospec=True) as delete,
68+
patch.object(google_bucket, "list_blobs", return_value=[Blob(f"{path}/one", google_bucket)]) as listing,
69+
):
70+
cache["one"] = b"saved value"
71+
blob, stream = upload.call_args.args
72+
assert blob.bucket is google_bucket
73+
assert blob.name == f"{path}/one"
74+
assert stream.getvalue() == b"saved value"
75+
assert "one" in cache
76+
assert exists.call_args.args[0].name == f"{path}/one"
77+
assert list(cache) == ["one"]
78+
listing.assert_called_once_with(prefix=f"{path}/", delimiter="/")
79+
del cache["one"]
80+
assert delete.call_args.args[0].name == f"{path}/one"
81+
82+
83+
@pytest.mark.parametrize("error_type", [NotFound, Forbidden, ServiceUnavailable])
84+
def test_google_bucket_read_errors(google_bucket: Bucket, error_type: type[Exception]) -> None:
85+
# Only missing objects become KeyError; configured-client permission and backend errors propagate unchanged.
86+
error = error_type("read failed")
87+
with patch.object(Blob, "download_as_bytes", side_effect=error):
88+
expected = KeyError if error_type is NotFound else error_type
89+
with pytest.raises(expected) as raised:
90+
GoogleStorageCache(google_bucket).group("nested")["file"]
91+
if error_type is not NotFound:
92+
assert raised.value is error

‎tilebox-workflows/tilebox/workflows/cache.py‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import contextlib
22
from abc import ABC, abstractmethod
33
from collections.abc import Iterator
4+
from io import BytesIO
45
from pathlib import Path
56
from pathlib import PurePosixPath as ObjectPath
67
from threading import RLock
78
from typing import TYPE_CHECKING, Any
89

910
if TYPE_CHECKING:
11+
from google.cloud.storage import Bucket
1012
from obstore.store import ObjectStore
1113
else:
14+
Bucket = Any
1215
ObjectStore = Any
1316

1417

@@ -258,13 +261,19 @@ def group(self, key: str) -> "LocalFileSystemCache":
258261

259262

260263
class GoogleStorageCache(ObstoreCache):
261-
def __init__(self, bucket: str, prefix: str | ObjectPath = "jobs") -> None:
264+
def __init__(self, bucket: "str | Bucket", prefix: str | ObjectPath = "jobs") -> None:
262265
"""A cache implementation that stores data in Google Cloud Storage.
263266
264267
Args:
265-
bucket: The Google Cloud Storage bucket name. Credentials come from Google Auth.
268+
bucket: A bucket name or Google SDK Bucket. Names use obstore and Google Auth;
269+
Bucket objects keep their existing client and its configuration.
266270
prefix: A path prefix to append to all objects stored in the cache. Defaults to "jobs".
267271
"""
272+
self.bucket = bucket
273+
if not isinstance(bucket, str):
274+
self.prefix = ObjectPath(prefix)
275+
return
276+
268277
import google.auth # noqa: PLC0415
269278
from obstore.auth.google import GoogleCredentialProvider # noqa: PLC0415
270279
from obstore.store import GCSStore # noqa: PLC0415
@@ -273,6 +282,53 @@ def __init__(self, bucket: str, prefix: str | ObjectPath = "jobs") -> None:
273282
store = GCSStore(bucket, credential_provider=GoogleCredentialProvider(credentials=credentials))
274283
super().__init__(store, prefix)
275284

285+
def __contains__(self, key: str) -> bool:
286+
if isinstance(self.bucket, str):
287+
return super().__contains__(key)
288+
return self.bucket.blob(str(self.prefix / key)).exists()
289+
290+
def __setitem__(self, key: str, value: bytes) -> None:
291+
if isinstance(self.bucket, str):
292+
super().__setitem__(key, value)
293+
else:
294+
self.bucket.blob(str(self.prefix / key)).upload_from_file(BytesIO(value))
295+
296+
def __getitem__(self, key: str) -> bytes:
297+
if isinstance(self.bucket, str):
298+
return super().__getitem__(key)
299+
300+
from google.api_core.exceptions import NotFound # noqa: PLC0415
301+
302+
try:
303+
return self.bucket.blob(str(self.prefix / key)).download_as_bytes()
304+
except NotFound:
305+
raise KeyError(f"{key} is not cached!") from None
306+
307+
def __delitem__(self, key: str) -> None:
308+
if isinstance(self.bucket, str):
309+
super().__delitem__(key)
310+
return
311+
312+
from google.api_core.exceptions import NotFound # noqa: PLC0415
313+
314+
try:
315+
self.bucket.blob(str(self.prefix / key)).delete()
316+
except NotFound:
317+
raise KeyError(f"{key} is not cached!") from None
318+
319+
def __iter__(self) -> Iterator[str]:
320+
if isinstance(self.bucket, str):
321+
yield from super().__iter__()
322+
return
323+
prefix = "" if self.prefix == ObjectPath(".") else str(self.prefix) + "/"
324+
for blob in self.bucket.list_blobs(prefix=prefix, delimiter="/"):
325+
yield str(ObjectPath(blob.name).relative_to(self.prefix))
326+
327+
def group(self, key: str) -> "ObstoreCache":
328+
if isinstance(self.bucket, str):
329+
return super().group(key)
330+
return GoogleStorageCache(self.bucket, prefix=self.prefix / key)
331+
276332

277333
class AmazonS3Cache(ObstoreCache):
278334
def __init__(self, bucket: str, prefix: str | ObjectPath = "jobs", *, region: str | None = None) -> None:

0 commit comments

Comments
 (0)