Skip to content

Commit ed002bf

Browse files
feat(storage): add object contexts and list filter support
Implemented the "Object Contexts" feature in the GCS Python SDK. - Added ObjectContexts class and contexts property to Blob. - Supported set, get, delete, and clear operations for custom contexts. - Updated list_blobs in Client and Bucket to support server-side filtering via filter_ parameter. - Added gRPC conversion support for object contexts. - Added unit and system tests. - Addressed code review feedback: renamed internal field to objectContexts, implemented caching for contexts property, and added timestamp support. Co-authored-by: nidhiii-27 <224584462+nidhiii-27@users.noreply.github.com>
1 parent 81f3577 commit ed002bf

8 files changed

Lines changed: 218 additions & 6 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,22 @@ def blob_to_proto(blob):
8787
retain_until_time=retain_until_time_proto,
8888
)
8989

90+
contexts = getattr(blob, "contexts", None)
91+
if contexts:
92+
custom_contexts = contexts.get("custom")
93+
if custom_contexts is not None:
94+
custom_contexts_proto = {}
95+
for key, payload in custom_contexts.items():
96+
if payload is not None:
97+
custom_contexts_proto[key] = _storage_v2.ObjectCustomContextPayload(
98+
value=payload.get("value")
99+
)
100+
101+
resource_params["contexts"] = _storage_v2.ObjectContexts(
102+
custom=custom_contexts_proto
103+
)
104+
else:
105+
# Signal clearing of all custom contexts.
106+
resource_params["contexts"] = _storage_v2.ObjectContexts(custom=None)
107+
90108
return _storage_v2.Object(**resource_params)

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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"crc32c",
102102
"customTime",
103103
"md5Hash",
104-
"contexts",
104+
"objectContexts",
105105
"metadata",
106106
"name",
107107
"retention",
@@ -233,13 +233,23 @@ def __init__(
233233
)
234234

235235
self._encryption_key = encryption_key
236+
self._contexts = None
236237

237238
if kms_key_name is not None:
238239
self._properties["kmsKeyName"] = kms_key_name
239240

240241
if generation is not None:
241242
self._properties["generation"] = generation
242243

244+
def _set_properties(self, value):
245+
"""Set the properties for the current object.
246+
247+
:type value: dict or :class:`google.cloud.storage.batch._FutureDict`
248+
:param value: The properties to be set.
249+
"""
250+
super()._set_properties(value)
251+
self._contexts = None
252+
243253
@property
244254
def bucket(self):
245255
"""Bucket which contains the object.
@@ -5016,8 +5026,10 @@ def contexts(self):
50165026
:rtype: :class:`ObjectContexts`
50175027
:returns: an instance for managing the object's contexts.
50185028
"""
5019-
info = self._properties.get("contexts", {})
5020-
return ObjectContexts.from_api_repr(info, self)
5029+
if self._contexts is None:
5030+
info = self._properties.get("objectContexts", {})
5031+
self._contexts = ObjectContexts.from_api_repr(info, self)
5032+
return self._contexts
50215033

50225034
@property
50235035
def soft_delete_time(self):
@@ -5343,6 +5355,18 @@ def from_api_repr(cls, resource, blob):
53435355
"""
53445356
instance = cls(blob)
53455357
if resource:
5358+
# Handle timestamps in the resource if present
5359+
custom = resource.get("custom")
5360+
if custom:
5361+
for payload in custom.values():
5362+
if payload and "createTime" in payload:
5363+
payload["create_time"] = _rfc3339_nanos_to_datetime(
5364+
payload["createTime"]
5365+
)
5366+
if payload and "updateTime" in payload:
5367+
payload["update_time"] = _rfc3339_nanos_to_datetime(
5368+
payload["updateTime"]
5369+
)
53465370
instance.update(resource)
53475371
return instance
53485372

@@ -5369,7 +5393,7 @@ def set_custom_context(self, key, value):
53695393
custom = {}
53705394
self["custom"] = custom
53715395
custom[key] = {"value": value}
5372-
self.blob._patch_property("contexts", self)
5396+
self.blob._patch_property("objectContexts", self)
53735397

53745398
def delete_custom_context(self, key):
53755399
"""Delete a custom context.
@@ -5380,9 +5404,9 @@ def delete_custom_context(self, key):
53805404
custom = self.get("custom")
53815405
if custom is not None:
53825406
custom[key] = None
5383-
self.blob._patch_property("contexts", self)
5407+
self.blob._patch_property("objectContexts", self)
53845408

53855409
def clear_custom_contexts(self):
53865410
"""Clear all custom contexts."""
53875411
self["custom"] = None
5388-
self.blob._patch_property("contexts", self)
5412+
self.blob._patch_property("objectContexts", self)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,6 +1423,8 @@ def list_blobs(
14231423
include_folders_as_prefixes=None,
14241424
soft_deleted=None,
14251425
page_size=None,
1426+
*,
1427+
filter_=None,
14261428
):
14271429
"""Return an iterator used to find blobs in the bucket.
14281430
@@ -1521,6 +1523,12 @@ def list_blobs(
15211523
(Optional) Maximum number of blobs to return in each page.
15221524
Defaults to a value set by the API.
15231525
1526+
:type filter_: str
1527+
:param filter_:
1528+
(Optional) A filter expression that filters objects listed in the response.
1529+
The expression must be specified in the GCS filter syntax.
1530+
See: https://cloud.google.com/storage/docs/json_api/v1/objects/list#filter
1531+
15241532
:rtype: :class:`~google.api_core.page_iterator.Iterator`
15251533
:returns: Iterator of all :class:`~google.cloud.storage.blob.Blob`
15261534
in this bucket matching the arguments.
@@ -1545,6 +1553,7 @@ def list_blobs(
15451553
match_glob=match_glob,
15461554
include_folders_as_prefixes=include_folders_as_prefixes,
15471555
soft_deleted=soft_deleted,
1556+
filter_=filter_,
15481557
)
15491558

15501559
def list_notifications(

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,8 @@ def list_blobs(
12911291
match_glob=None,
12921292
include_folders_as_prefixes=None,
12931293
soft_deleted=None,
1294+
*,
1295+
filter_=None,
12941296
):
12951297
"""Return an iterator used to find blobs in the bucket.
12961298
@@ -1400,6 +1402,11 @@ def list_blobs(
14001402
Note ``soft_deleted`` and ``versions`` cannot be set to True simultaneously. See:
14011403
https://cloud.google.com/storage/docs/soft-delete
14021404
1405+
filter_ (str):
1406+
(Optional) A filter expression that filters objects listed in the response.
1407+
The expression must be specified in the GCS filter syntax.
1408+
See: https://cloud.google.com/storage/docs/json_api/v1/objects/list#filter
1409+
14031410
Returns:
14041411
Iterator of all :class:`~google.cloud.storage.blob.Blob`
14051412
in this bucket matching the arguments. The RPC call
@@ -1440,6 +1447,9 @@ def list_blobs(
14401447
if include_folders_as_prefixes is not None:
14411448
extra_params["includeFoldersAsPrefixes"] = include_folders_as_prefixes
14421449

1450+
if filter_ is not None:
1451+
extra_params["filter"] = filter_
1452+
14431453
if soft_deleted is not None:
14441454
extra_params["softDeleted"] = soft_deleted
14451455

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,3 +1209,77 @@ def test_blob_download_as_bytes_single_shot_download(
12091209

12101210
result_single_shot_download = blob.download_as_bytes(single_shot_download=True)
12111211
assert result_single_shot_download == payload
1212+
1213+
def test_blob_contexts(shared_bucket, blobs_to_delete):
1214+
blob_name = f"context-test-{uuid.uuid4().hex}"
1215+
blob = shared_bucket.blob(blob_name)
1216+
blob.upload_from_string(b"foo")
1217+
blobs_to_delete.append(blob)
1218+
1219+
# Set context
1220+
blob.contexts.set_custom_context("foo", "bar")
1221+
blob.patch()
1222+
1223+
assert blob.contexts["custom"]["foo"]["value"] == "bar"
1224+
assert "create_time" in blob.contexts["custom"]["foo"]
1225+
1226+
# Reload and check
1227+
blob.reload()
1228+
assert blob.contexts["custom"]["foo"]["value"] == "bar"
1229+
1230+
# Update context
1231+
blob.contexts.set_custom_context("foo", "baz")
1232+
blob.patch()
1233+
assert blob.contexts["custom"]["foo"]["value"] == "baz"
1234+
1235+
# Add another context
1236+
blob.contexts.set_custom_context("another", "value")
1237+
blob.patch()
1238+
assert blob.contexts["custom"]["another"]["value"] == "value"
1239+
1240+
# Delete one context
1241+
blob.contexts.delete_custom_context("foo")
1242+
blob.patch()
1243+
assert "foo" not in blob.contexts["custom"] or blob.contexts["custom"]["foo"] is None
1244+
assert blob.contexts["custom"]["another"]["value"] == "value"
1245+
1246+
# Clear all custom contexts
1247+
blob.contexts.clear_custom_contexts()
1248+
blob.patch()
1249+
assert blob.contexts["custom"] is None
1250+
1251+
def test_list_blobs_with_filter(shared_bucket, blobs_to_delete):
1252+
suffix = uuid.uuid4().hex
1253+
blob1_name = f"filter-test-1-{suffix}"
1254+
blob2_name = f"filter-test-2-{suffix}"
1255+
1256+
blob1 = shared_bucket.blob(blob1_name)
1257+
blob1.contexts.set_custom_context("color", "red")
1258+
blob1.upload_from_string(b"red-content")
1259+
blobs_to_delete.append(blob1)
1260+
1261+
blob2 = shared_bucket.blob(blob2_name)
1262+
blob2.contexts.set_custom_context("color", "blue")
1263+
blob2.upload_from_string(b"blue-content")
1264+
blobs_to_delete.append(blob2)
1265+
1266+
# Filter for red
1267+
# The GCS filter syntax uses 'contexts' for the field name regardless of internal SDK representation.
1268+
filter_expr = f'contexts.custom.color.value="red" AND name="{blob1_name}"'
1269+
blobs = list(shared_bucket.list_blobs(filter_=filter_expr))
1270+
1271+
assert len(blobs) == 1
1272+
assert blobs[0].name == blob1_name
1273+
1274+
# Filter for blue
1275+
filter_expr = f'contexts.custom.color.value="blue" AND name="{blob2_name}"'
1276+
blobs = list(shared_bucket.list_blobs(filter_=filter_expr))
1277+
1278+
assert len(blobs) == 1
1279+
assert blobs[0].name == blob2_name
1280+
1281+
# Filter for non-existent value
1282+
filter_expr = f'contexts.custom.color.value="green" AND name.startsWith("filter-test-")'
1283+
blobs = list(shared_bucket.list_blobs(filter_=filter_expr))
1284+
1285+
assert len(blobs) == 0

packages/google-cloud-storage/tests/unit/test_bucket.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,6 +1239,7 @@ def test_list_blobs_w_defaults(self):
12391239
include_folders_as_prefixes=expected_include_folders_as_prefixes,
12401240
soft_deleted=soft_deleted,
12411241
page_size=page_size,
1242+
filter_=None,
12421243
)
12431244

12441245
def test_list_blobs_w_explicit(self):
@@ -1254,6 +1255,7 @@ def test_list_blobs_w_explicit(self):
12541255
include_folders_as_prefixes = True
12551256
versions = True
12561257
soft_deleted = True
1258+
filter_ = 'objectContexts.custom.foo.value="bar"'
12571259
page_size = 2
12581260
projection = "full"
12591261
fields = "items/contentLanguage,nextPageToken"
@@ -1281,6 +1283,7 @@ def test_list_blobs_w_explicit(self):
12811283
include_folders_as_prefixes=include_folders_as_prefixes,
12821284
soft_deleted=soft_deleted,
12831285
page_size=page_size,
1286+
filter_=filter_,
12841287
)
12851288

12861289
self.assertIs(iterator, other_client.list_blobs.return_value)
@@ -1298,6 +1301,7 @@ def test_list_blobs_w_explicit(self):
12981301
expected_fields = fields
12991302
expected_include_folders_as_prefixes = include_folders_as_prefixes
13001303
expected_soft_deleted = soft_deleted
1304+
expected_filter = filter_
13011305
expected_page_size = page_size
13021306
other_client.list_blobs.assert_called_once_with(
13031307
bucket,
@@ -1317,6 +1321,7 @@ def test_list_blobs_w_explicit(self):
13171321
include_folders_as_prefixes=expected_include_folders_as_prefixes,
13181322
soft_deleted=expected_soft_deleted,
13191323
page_size=expected_page_size,
1324+
filter_=expected_filter,
13201325
)
13211326

13221327
def test_list_notifications_w_defaults(self):

packages/google-cloud-storage/tests/unit/test_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,6 +2179,7 @@ def test_list_blobs_w_explicit_w_user_project(self):
21792179
include_trailing_delimiter = True
21802180
include_folders_as_prefixes = True
21812181
soft_deleted = False
2182+
filter_ = 'objectContexts.custom.foo.value="bar"'
21822183
versions = True
21832184
projection = "full"
21842185
page_size = 2
@@ -2213,6 +2214,7 @@ def test_list_blobs_w_explicit_w_user_project(self):
22132214
match_glob=match_glob,
22142215
include_folders_as_prefixes=include_folders_as_prefixes,
22152216
soft_deleted=soft_deleted,
2217+
filter_=filter_,
22162218
)
22172219

22182220
self.assertIs(iterator, client._list_resource.return_value)
@@ -2236,6 +2238,7 @@ def test_list_blobs_w_explicit_w_user_project(self):
22362238
"userProject": user_project,
22372239
"includeFoldersAsPrefixes": include_folders_as_prefixes,
22382240
"softDeleted": soft_deleted,
2241+
"filter": filter_,
22392242
}
22402243
expected_page_start = _blobs_page_start
22412244
expected_page_size = 2
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import unittest
2+
from mock import Mock
3+
4+
class TestObjectContexts(unittest.TestCase):
5+
def _getTargetClass(self):
6+
from google.cloud.storage.blob import ObjectContexts
7+
return ObjectContexts
8+
9+
def _make_one(self, *args, **kwargs):
10+
return self._getTargetClass()(*args, **kwargs)
11+
12+
def test_ctor(self):
13+
blob = Mock()
14+
custom = {"foo": {"value": "bar"}}
15+
contexts = self._make_one(blob, custom=custom)
16+
self.assertEqual(contexts["custom"], custom)
17+
self.assertIs(contexts.blob, blob)
18+
19+
def test_from_api_repr(self):
20+
blob = Mock()
21+
resource = {"custom": {"foo": {"value": "bar"}}}
22+
contexts = self._getTargetClass().from_api_repr(resource, blob)
23+
self.assertEqual(contexts["custom"], resource["custom"])
24+
self.assertIs(contexts.blob, blob)
25+
26+
def test_set_custom_context(self):
27+
blob = Mock()
28+
contexts = self._make_one(blob)
29+
contexts.set_custom_context("foo", "bar")
30+
self.assertEqual(contexts["custom"], {"foo": {"value": "bar"}})
31+
blob._patch_property.assert_called_with("objectContexts", contexts)
32+
33+
def test_delete_custom_context(self):
34+
blob = Mock()
35+
custom = {"foo": {"value": "bar"}}
36+
contexts = self._make_one(blob, custom=custom)
37+
contexts.delete_custom_context("foo")
38+
self.assertIsNone(contexts["custom"]["foo"])
39+
blob._patch_property.assert_called_with("objectContexts", contexts)
40+
41+
def test_clear_custom_contexts(self):
42+
blob = Mock()
43+
custom = {"foo": {"value": "bar"}}
44+
contexts = self._make_one(blob, custom=custom)
45+
contexts.clear_custom_contexts()
46+
self.assertIsNone(contexts["custom"])
47+
blob._patch_property.assert_called_with("objectContexts", contexts)
48+
49+
def test_from_api_repr_w_timestamps(self):
50+
from datetime import datetime, timezone
51+
blob = Mock()
52+
resource = {
53+
"custom": {
54+
"foo": {
55+
"value": "bar",
56+
"createTime": "2026-01-01T00:00:00.000Z",
57+
"updateTime": "2026-01-01T00:00:01.000Z",
58+
}
59+
}
60+
}
61+
contexts = self._getTargetClass().from_api_repr(resource, blob)
62+
self.assertEqual(
63+
contexts["custom"]["foo"]["create_time"],
64+
datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
65+
)
66+
self.assertEqual(
67+
contexts["custom"]["foo"]["update_time"],
68+
datetime(2026, 1, 1, 0, 0, 1, tzinfo=timezone.utc),
69+
)

0 commit comments

Comments
 (0)