Skip to content

Commit 28487f5

Browse files
feat: add fixed-key metadata support in AAOW (#16817)
Add fixed-key metadata support in AAOW by updating `blob_to_proto` conversion logic. - Updated `_grpc_conversions.py` with simple and complex field mappings. - Added unit tests in `tests/unit/test__grpc_conversions.py`. - Updated system tests in `tests/system/test_zonal.py`. - Fixed regression in `tests/unit/asyncio/test_async_write_object_stream.py`. --- *PR created automatically by Jules for task [11384837182247010380](https://jules.google.com/task/11384837182247010380) started by @nidhiii-27* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: nidhiii-27 <224584462+nidhiii-27@users.noreply.github.com>
1 parent 0500c76 commit 28487f5

4 files changed

Lines changed: 247 additions & 1 deletion

File tree

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

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

1515
fromgoogle.cloudimport_storage_v2
16+
fromgoogle.protobufimporttimestamp_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,46 @@ def blob_to_proto(blob):
3744
ifvalueisnotNone:
3845
resource_params[proto_field] =value
3946

47+
custom_time=getattr(blob, "custom_time", None)
48+
ifcustom_timeisnotNone:
49+
custom_time_proto=timestamp_pb2.Timestamp()
50+
custom_time_proto.FromDatetime(custom_time)
51+
resource_params["custom_time"] =custom_time_proto
52+
53+
acl=getattr(blob, "acl", None)
54+
ifaclisnotNoneandgetattr(acl, "loaded", False):
55+
acl_entries= []
56+
forentryinacl:
57+
acl_entries.append(
58+
_storage_v2.ObjectAccessControl(
59+
role=entry["role"],
60+
entity=entry["entity"],
61+
)
62+
)
63+
ifacl_entries:
64+
resource_params["acl"] =acl_entries
65+
66+
retention=getattr(blob, "retention", None)
67+
ifretention:
68+
mode_str=retention.get("mode")
69+
mode=_storage_v2.Object.Retention.Mode.MODE_UNSPECIFIED
70+
ifmode_str:
71+
# GCS retention modes are 'Locked' or 'Unlocked'
72+
mode=getattr(
73+
_storage_v2.Object.Retention.Mode,
74+
mode_str.upper(),
75+
_storage_v2.Object.Retention.Mode.MODE_UNSPECIFIED,
76+
)
77+
78+
retain_until_time_proto=None
79+
retain_until_time=retention.get("retain_until_time")
80+
ifretain_until_timeisnotNone:
81+
retain_until_time_proto=timestamp_pb2.Timestamp()
82+
retain_until_time_proto.FromDatetime(retain_until_time)
83+
84+
resource_params["retention"] =_storage_v2.Object.Retention(
85+
mode=mode,
86+
retain_until_time=retain_until_time_proto,
87+
)
88+
4089
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
importasyncio
3+
importdatetime
34
importgc
45
importos
56
importrandom
@@ -347,13 +348,23 @@ def test_write_from_blob(
347348
object_name=f"test_from_blob-{str(uuid.uuid4())[:4]}"
348349
content_type="text/plain"
349350
metadata= {"environment": "system-test"}
351+
cache_control="public, max-age=3600"
352+
content_disposition="attachment; filename=test.txt"
353+
content_encoding="identity"
354+
content_language="en"
355+
custom_time=datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
350356
test_data=b"system-test-data"
351357

352358
asyncdef_run():
353359
# 1. Create a Blob instance
354360
blob=storage_client.bucket(_ZONAL_BUCKET).blob(object_name)
355361
blob.content_type=content_type
356362
blob.metadata=metadata
363+
blob.cache_control=cache_control
364+
blob.content_disposition=content_disposition
365+
blob.content_encoding=content_encoding
366+
blob.content_language=content_language
367+
blob.custom_time=custom_time
357368

358369
# 2. Use from_blob to create the writer
359370
writer=AsyncAppendableObjectWriter.from_blob(grpc_client, blob)
@@ -369,6 +380,11 @@ async def _run():
369380

370381
assertobj.content_type==content_type
371382
assertobj.metadata["environment"] =="system-test"
383+
assertobj.cache_control==cache_control
384+
assertobj.content_disposition==content_disposition
385+
assertobj.content_encoding==content_encoding
386+
assertobj.content_language==content_language
387+
assertint(obj.custom_time.timestamp()) ==int(custom_time.timestamp())
372388

373389
blobs_to_delete.append(blob)
374390

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
importdatetime
1516
importunittest.mockasmock
1617
fromunittest.mockimportAsyncMock, MagicMock
1718

@@ -169,7 +170,33 @@ async def test_open_new_object_with_blob_sync_attrs(
169170
mock_blob.bucket=mock_bucket
170171
mock_blob.content_type="text/plain"
171172
mock_blob.metadata= {"test-key": "test-value"}
172-
mock_blob.kms_key_name=None
173+
mock_blob.kms_key_name="kms-key-name"
174+
mock_blob.cache_control="cache-control"
175+
mock_blob.content_disposition="content-disposition"
176+
mock_blob.content_encoding="content-encoding"
177+
mock_blob.content_language="content-language"
178+
mock_blob.temporary_hold=True
179+
mock_blob.event_based_hold=True
180+
181+
custom_time=datetime.datetime(
182+
2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
183+
)
184+
mock_blob.custom_time=custom_time
185+
186+
acl_mock=MagicMock()
187+
acl_mock.loaded=True
188+
acl_mock.__iter__.return_value=iter(
189+
[{"role": "READER", "entity": "allUsers"}]
190+
)
191+
mock_blob.acl=acl_mock
192+
193+
retain_until_time=datetime.datetime(
194+
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
195+
)
196+
mock_blob.retention= {
197+
"mode": "Locked",
198+
"retain_until_time": retain_until_time,
199+
}
173200

174201
stream=_AsyncWriteObjectStream(mock_client, BUCKET, OBJECT, blob=mock_blob)
175202
awaitstream.open()
@@ -180,6 +207,24 @@ async def test_open_new_object_with_blob_sync_attrs(
180207

181208
assertresource.content_type=="text/plain"
182209
assertresource.metadata== {"test-key": "test-value"}
210+
assertresource.kms_key=="kms-key-name"
211+
assertresource.cache_control=="cache-control"
212+
assertresource.content_disposition=="content-disposition"
213+
assertresource.content_encoding=="content-encoding"
214+
assertresource.content_language=="content-language"
215+
assertresource.temporary_holdisTrue
216+
assertresource.event_based_holdisTrue
217+
218+
assertint(resource.custom_time.timestamp()) ==int(custom_time.timestamp())
219+
220+
assertlen(resource.acl) ==1
221+
assertresource.acl[0].role=="READER"
222+
assertresource.acl[0].entity=="allUsers"
223+
224+
assertresource.retention.mode==_storage_v2.Object.Retention.Mode.LOCKED
225+
assertint(resource.retention.retain_until_time.timestamp()) ==int(
226+
retain_until_time.timestamp()
227+
)
183228

184229
@pytest.mark.asyncio
185230
asyncdeftest_open_already_open_raises(self, mock_client):
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
importdatetime
16+
fromunittestimportmock
17+
18+
fromgoogle.cloud.storageimport_grpc_conversions
19+
fromgoogle.cloudimport_storage_v2
20+
21+
22+
deftest_blob_to_proto_simple_fields():
23+
blob=mock.Mock(
24+
spec=[
25+
"name",
26+
"bucket",
27+
"content_type",
28+
"metadata",
29+
"kms_key_name",
30+
"cache_control",
31+
"content_disposition",
32+
"content_encoding",
33+
"content_language",
34+
"temporary_hold",
35+
"event_based_hold",
36+
"custom_time",
37+
"acl",
38+
"retention",
39+
]
40+
)
41+
blob.name="blob-name"
42+
blob.bucket.name="bucket-name"
43+
blob.content_type="text/plain"
44+
blob.metadata= {"key": "value"}
45+
blob.kms_key_name="kms-key"
46+
blob.cache_control="no-cache"
47+
blob.content_disposition="attachment"
48+
blob.content_encoding="gzip"
49+
blob.content_language="en"
50+
blob.temporary_hold=True
51+
blob.event_based_hold=False
52+
blob.custom_time=None
53+
blob.acl=None
54+
blob.retention=None
55+
56+
proto=_grpc_conversions.blob_to_proto(blob)
57+
58+
assertproto.name=="blob-name"
59+
assertproto.bucket=="projects/_/buckets/bucket-name"
60+
assertproto.content_type=="text/plain"
61+
assertproto.metadata== {"key": "value"}
62+
assertproto.kms_key=="kms-key"
63+
assertproto.cache_control=="no-cache"
64+
assertproto.content_disposition=="attachment"
65+
assertproto.content_encoding=="gzip"
66+
assertproto.content_language=="en"
67+
assertproto.temporary_holdisTrue
68+
assertproto.event_based_holdisFalse
69+
70+
71+
deftest_blob_to_proto_custom_time():
72+
blob=mock.Mock(spec=["name", "bucket", "custom_time", "acl", "retention"])
73+
blob.name="blob-name"
74+
blob.bucket.name="bucket-name"
75+
blob.custom_time=datetime.datetime(
76+
2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
77+
)
78+
blob.acl=None
79+
blob.retention=None
80+
# ensure other fields don't cause issues if missing
81+
forattrin_grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
82+
setattr(blob, attr, None)
83+
84+
proto=_grpc_conversions.blob_to_proto(blob)
85+
86+
assertint(proto.custom_time.timestamp()) ==int(blob.custom_time.timestamp())
87+
88+
89+
deftest_blob_to_proto_acl():
90+
blob=mock.Mock(spec=["name", "bucket", "acl", "custom_time", "retention"])
91+
blob.name="blob-name"
92+
blob.bucket.name="bucket-name"
93+
94+
acl_mock=mock.MagicMock()
95+
acl_mock.loaded=True
96+
acl_mock.__iter__.return_value=iter(
97+
[
98+
{"role": "READER", "entity": "allUsers"},
99+
{"role": "OWNER", "entity": "user-123"},
100+
]
101+
)
102+
blob.acl=acl_mock
103+
104+
blob.custom_time=None
105+
blob.retention=None
106+
forattrin_grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
107+
setattr(blob, attr, None)
108+
109+
proto=_grpc_conversions.blob_to_proto(blob)
110+
111+
assertlen(proto.acl) ==2
112+
assertproto.acl[0].role=="READER"
113+
assertproto.acl[0].entity=="allUsers"
114+
assertproto.acl[1].role=="OWNER"
115+
assertproto.acl[1].entity=="user-123"
116+
117+
118+
deftest_blob_to_proto_retention():
119+
blob=mock.Mock(spec=["name", "bucket", "retention", "custom_time", "acl"])
120+
blob.name="blob-name"
121+
blob.bucket.name="bucket-name"
122+
123+
retain_until_time=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
124+
blob.retention= {"mode": "Locked", "retain_until_time": retain_until_time}
125+
126+
blob.custom_time=None
127+
blob.acl=None
128+
forattrin_grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
129+
setattr(blob, attr, None)
130+
131+
proto=_grpc_conversions.blob_to_proto(blob)
132+
133+
assertproto.retention.mode==_storage_v2.Object.Retention.Mode.LOCKED
134+
assertint(proto.retention.retain_until_time.timestamp()) ==int(
135+
retain_until_time.timestamp()
136+
)

0 commit comments

Comments
 (0)