Skip to content

Commit 28487f5

Browse files
feat: add fixed-key metadata support in AAOW (googleapis#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
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,46 @@ def blob_to_proto(blob):
3744
if value is not None:
3845
resource_params[proto_field] = value
3946

47+
custom_time = getattr(blob, "custom_time", None)
48+
if custom_time is not None:
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+
if acl is not None and getattr(acl, "loaded", False):
55+
acl_entries = []
56+
for entry in acl:
57+
acl_entries.append(
58+
_storage_v2.ObjectAccessControl(
59+
role=entry["role"],
60+
entity=entry["entity"],
61+
)
62+
)
63+
if acl_entries:
64+
resource_params["acl"] = acl_entries
65+
66+
retention = getattr(blob, "retention", None)
67+
if retention:
68+
mode_str = retention.get("mode")
69+
mode = _storage_v2.Object.Retention.Mode.MODE_UNSPECIFIED
70+
if mode_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+
if retain_until_time is not None:
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
import asyncio
3+
import datetime
34
import gc
45
import os
56
import random
@@ -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
async def _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
assert obj.content_type == content_type
371382
assert obj.metadata["environment"] == "system-test"
383+
assert obj.cache_control == cache_control
384+
assert obj.content_disposition == content_disposition
385+
assert obj.content_encoding == content_encoding
386+
assert obj.content_language == content_language
387+
assert int(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+
import datetime
1516
import unittest.mock as mock
1617
from unittest.mock import AsyncMock, 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
await stream.open()
@@ -180,6 +207,24 @@ async def test_open_new_object_with_blob_sync_attrs(
180207

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

184229
@pytest.mark.asyncio
185230
async def test_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+
import datetime
16+
from unittest import mock
17+
18+
from google.cloud.storage import _grpc_conversions
19+
from google.cloud import _storage_v2
20+
21+
22+
def test_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+
assert proto.name == "blob-name"
59+
assert proto.bucket == "projects/_/buckets/bucket-name"
60+
assert proto.content_type == "text/plain"
61+
assert proto.metadata == {"key": "value"}
62+
assert proto.kms_key == "kms-key"
63+
assert proto.cache_control == "no-cache"
64+
assert proto.content_disposition == "attachment"
65+
assert proto.content_encoding == "gzip"
66+
assert proto.content_language == "en"
67+
assert proto.temporary_hold is True
68+
assert proto.event_based_hold is False
69+
70+
71+
def test_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+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
82+
setattr(blob, attr, None)
83+
84+
proto = _grpc_conversions.blob_to_proto(blob)
85+
86+
assert int(proto.custom_time.timestamp()) == int(blob.custom_time.timestamp())
87+
88+
89+
def test_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+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
107+
setattr(blob, attr, None)
108+
109+
proto = _grpc_conversions.blob_to_proto(blob)
110+
111+
assert len(proto.acl) == 2
112+
assert proto.acl[0].role == "READER"
113+
assert proto.acl[0].entity == "allUsers"
114+
assert proto.acl[1].role == "OWNER"
115+
assert proto.acl[1].entity == "user-123"
116+
117+
118+
def test_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+
for attr in _grpc_conversions._BLOB_ATTR_TO_PROTO_FIELD:
129+
setattr(blob, attr, None)
130+
131+
proto = _grpc_conversions.blob_to_proto(blob)
132+
133+
assert proto.retention.mode == _storage_v2.Object.Retention.Mode.LOCKED
134+
assert int(proto.retention.retain_until_time.timestamp()) == int(
135+
retain_until_time.timestamp()
136+
)

0 commit comments

Comments
 (0)