Skip to content

Commit f64d6ea

Browse files
feat: add fixed-key metadata support in AsyncAppendableObjectWriter
This change extends `blob_to_proto` in `_grpc_conversions.py` to support additional fixed-key metadata fields when creating an object via the gRPC-based AsyncAppendableObjectWriter. Supported fields added: - Simple mappings: cache_control, content_disposition, content_encoding, content_language, temporary_hold, event_based_hold. - Complex mappings: custom_time (datetime), acl (ObjectAccessControl), contexts (ObjectContexts), and retention (Object.Retention). Also updated unit tests and system tests to verify these new fields. Co-authored-by: nidhiii-27 <224584462+nidhiii-27@users.noreply.github.com>
1 parent c215ae3 commit f64d6ea

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

packages/google-cloud-storage/google/cloud/storage/_grpc_conversions.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,19 @@
1313
# limitations under the License.
1414

1515
from google.cloud import _storage_v2
16+
from google.protobuf import timestamp_pb2
1617

1718
# Map Python Blob attributes to GCS V2 Object proto field names.
1819
_BLOB_ATTR_TO_PROTO_FIELD = {
1920
"content_type": "content_type",
2021
"metadata": "metadata",
2122
"kms_key_name": "kms_key",
23+
"cache_control": "cache_control",
24+
"content_disposition": "content_disposition",
25+
"content_encoding": "content_encoding",
26+
"content_language": "content_language",
27+
"temporary_hold": "temporary_hold",
28+
"event_based_hold": "event_based_hold",
2229
}
2330

2431

@@ -37,4 +44,65 @@ def blob_to_proto(blob):
3744
if value is not None:
3845
resource_params[proto_field] = value
3946

47+
# custom_time (field 26): google.protobuf.Timestamp
48+
custom_time = getattr(blob, "custom_time", None)
49+
if custom_time is not None:
50+
custom_time_proto = timestamp_pb2.Timestamp()
51+
custom_time_proto.FromDatetime(custom_time)
52+
resource_params["custom_time"] = custom_time_proto
53+
54+
# acl (field 10): repeated ObjectAccessControl
55+
acl = getattr(blob, "acl", None)
56+
if acl is not None and getattr(acl, "loaded", False):
57+
acl_entries = []
58+
for entry in acl:
59+
acl_entries.append(
60+
_storage_v2.ObjectAccessControl(
61+
role=entry["role"],
62+
entity=entry["entity"],
63+
)
64+
)
65+
if acl_entries:
66+
resource_params["acl"] = acl_entries
67+
68+
# contexts (field 38): ObjectContexts
69+
contexts = getattr(blob, "contexts", None)
70+
if contexts is not None:
71+
custom_map = {}
72+
# contexts is expected to be a dict of key-value pairs
73+
if isinstance(contexts, dict):
74+
for k, v in contexts.items():
75+
if isinstance(v, str):
76+
payload = _storage_v2.ObjectCustomContextPayload(value=v)
77+
else:
78+
payload = v
79+
custom_map[k] = payload
80+
81+
if custom_map:
82+
resource_params["contexts"] = _storage_v2.ObjectContexts(custom=custom_map)
83+
84+
# retention (field 30): Object.Retention
85+
retention = getattr(blob, "retention", None)
86+
if retention:
87+
mode_str = retention.get("mode")
88+
mode = _storage_v2.Object.Retention.Mode.MODE_UNSPECIFIED
89+
if mode_str:
90+
# GCS retention modes are 'Locked' or 'Unlocked'
91+
mode = getattr(
92+
_storage_v2.Object.Retention.Mode,
93+
mode_str.upper(),
94+
_storage_v2.Object.Retention.Mode.MODE_UNSPECIFIED,
95+
)
96+
97+
retain_until_time_proto = None
98+
retain_until_time = retention.get("retain_until_time")
99+
if retain_until_time is not None:
100+
retain_until_time_proto = timestamp_pb2.Timestamp()
101+
retain_until_time_proto.FromDatetime(retain_until_time)
102+
103+
resource_params["retention"] = _storage_v2.Object.Retention(
104+
mode=mode,
105+
retain_until_time=retain_until_time_proto,
106+
)
107+
40108
return _storage_v2.Object(**resource_params)

packages/google-cloud-storage/tests/system/test_zonal.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# py standard imports
22
import asyncio
3+
import datetime
34
import gc
45
import os
56
import random
@@ -348,13 +349,23 @@ def test_write_from_blob(
348349
object_name = f"test_from_blob-{str(uuid.uuid4())[:4]}"
349350
content_type = "text/plain"
350351
metadata = {"environment": "system-test"}
352+
cache_control = "public, max-age=3600"
353+
content_disposition = "attachment; filename=test.txt"
354+
content_encoding = "identity"
355+
content_language = "en"
356+
custom_time = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
351357
test_data = b"system-test-data"
352358

353359
async def _run():
354360
# 1. Create a Blob instance
355361
blob = storage_client.bucket(_ZONAL_BUCKET).blob(object_name)
356362
blob.content_type = content_type
357363
blob.metadata = metadata
364+
blob.cache_control = cache_control
365+
blob.content_disposition = content_disposition
366+
blob.content_encoding = content_encoding
367+
blob.content_language = content_language
368+
blob.custom_time = custom_time
358369

359370
# 2. Use from_blob to create the writer
360371
writer = AsyncAppendableObjectWriter.from_blob(grpc_client, blob)
@@ -370,6 +381,11 @@ async def _run():
370381

371382
assert obj.content_type == content_type
372383
assert obj.metadata["environment"] == "system-test"
384+
assert obj.cache_control == cache_control
385+
assert obj.content_disposition == content_disposition
386+
assert obj.content_encoding == content_encoding
387+
assert obj.content_language == content_language
388+
assert int(obj.custom_time.timestamp()) == int(custom_time.timestamp())
373389

374390
blobs_to_delete.append(blob)
375391

packages/google-cloud-storage/tests/unit/asyncio/test_async_write_object_stream.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,16 @@ async def test_open_new_object_with_blob_sync_attrs(
171171
mock_blob.content_type = "text/plain"
172172
mock_blob.metadata = {"test-key": "test-value"}
173173
mock_blob.kms_key_name = None
174+
mock_blob.cache_control = None
175+
mock_blob.content_disposition = None
176+
mock_blob.content_encoding = None
177+
mock_blob.content_language = None
178+
mock_blob.temporary_hold = None
179+
mock_blob.event_based_hold = None
180+
mock_blob.custom_time = None
181+
mock_blob.acl = None
182+
mock_blob.retention = None
183+
mock_blob.contexts = None
174184

175185
stream = _AsyncWriteObjectStream(mock_client, BUCKET, OBJECT, blob=mock_blob)
176186
await stream.open()
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
from unittest import mock
17+
18+
from google.cloud.storage import _grpc_conversions
19+
from google.cloud import _storage_v2
20+
from google.protobuf import timestamp_pb2
21+
22+
23+
def test_blob_to_proto_simple_fields():
24+
blob = mock.Mock(spec=["name", "bucket", "content_type", "metadata", "kms_key_name", "cache_control", "content_disposition", "content_encoding", "content_language", "temporary_hold", "event_based_hold", "custom_time", "acl", "retention", "contexts"])
25+
blob.name = "blob-name"
26+
blob.bucket.name = "bucket-name"
27+
blob.content_type = "text/plain"
28+
blob.metadata = {"key": "value"}
29+
blob.kms_key_name = "kms-key"
30+
blob.cache_control = "no-cache"
31+
blob.content_disposition = "attachment"
32+
blob.content_encoding = "gzip"
33+
blob.content_language = "en"
34+
blob.temporary_hold = True
35+
blob.event_based_hold = False
36+
blob.custom_time = None
37+
blob.acl = None
38+
blob.retention = None
39+
blob.contexts = None
40+
41+
proto = _grpc_conversions.blob_to_proto(blob)
42+
43+
assert proto.name == "blob-name"
44+
assert proto.bucket == "projects/_/buckets/bucket-name"
45+
assert proto.content_type == "text/plain"
46+
assert proto.metadata == {"key": "value"}
47+
assert proto.kms_key == "kms-key"
48+
assert proto.cache_control == "no-cache"
49+
assert proto.content_disposition == "attachment"
50+
assert proto.content_encoding == "gzip"
51+
assert proto.content_language == "en"
52+
assert proto.temporary_hold is True
53+
assert proto.event_based_hold is False
54+
55+
56+
def test_blob_to_proto_custom_time():
57+
blob = mock.Mock(spec=["name", "bucket", "custom_time", "acl", "retention", "contexts"])
58+
blob.name = "blob-name"
59+
blob.bucket.name = "bucket-name"
60+
blob.custom_time = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
61+
blob.acl = None
62+
blob.retention = None
63+
blob.contexts = None
64+
# ensure other fields don't cause issues if missing
65+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
66+
setattr(blob, attr, None)
67+
68+
proto = _grpc_conversions.blob_to_proto(blob)
69+
70+
assert int(proto.custom_time.timestamp()) == int(blob.custom_time.timestamp())
71+
72+
73+
def test_blob_to_proto_acl():
74+
blob = mock.Mock(spec=["name", "bucket", "acl", "custom_time", "retention", "contexts"])
75+
blob.name = "blob-name"
76+
blob.bucket.name = "bucket-name"
77+
78+
acl_mock = mock.MagicMock()
79+
acl_mock.loaded = True
80+
acl_mock.__iter__.return_value = iter([
81+
{"role": "READER", "entity": "allUsers"},
82+
{"role": "OWNER", "entity": "user-123"},
83+
])
84+
blob.acl = acl_mock
85+
86+
blob.custom_time = None
87+
blob.retention = None
88+
blob.contexts = None
89+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
90+
setattr(blob, attr, None)
91+
92+
proto = _grpc_conversions.blob_to_proto(blob)
93+
94+
assert len(proto.acl) == 2
95+
assert proto.acl[0].role == "READER"
96+
assert proto.acl[0].entity == "allUsers"
97+
assert proto.acl[1].role == "OWNER"
98+
assert proto.acl[1].entity == "user-123"
99+
100+
101+
def test_blob_to_proto_contexts():
102+
blob = mock.Mock(spec=["name", "bucket", "contexts", "custom_time", "acl", "retention"])
103+
blob.name = "blob-name"
104+
blob.bucket.name = "bucket-name"
105+
blob.contexts = {"c1": "v1", "c2": "v2"}
106+
blob.custom_time = None
107+
blob.acl = None
108+
blob.retention = None
109+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
110+
setattr(blob, attr, None)
111+
112+
proto = _grpc_conversions.blob_to_proto(blob)
113+
114+
assert len(proto.contexts.custom) == 2
115+
contexts_dict = {k: v.value for k, v in proto.contexts.custom.items()}
116+
assert contexts_dict == {"c1": "v1", "c2": "v2"}
117+
118+
119+
def test_blob_to_proto_retention():
120+
blob = mock.Mock(spec=["name", "bucket", "retention", "custom_time", "acl", "contexts"])
121+
blob.name = "blob-name"
122+
blob.bucket.name = "bucket-name"
123+
124+
retain_until_time = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
125+
blob.retention = {
126+
"mode": "Locked",
127+
"retain_until_time": retain_until_time
128+
}
129+
130+
blob.custom_time = None
131+
blob.acl = None
132+
blob.contexts = None
133+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
134+
setattr(blob, attr, None)
135+
136+
proto = _grpc_conversions.blob_to_proto(blob)
137+
138+
assert proto.retention.mode == _storage_v2.Object.Retention.Mode.LOCKED
139+
assert int(proto.retention.retain_until_time.timestamp()) == int(retain_until_time.timestamp())

0 commit comments

Comments
 (0)