Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"content_type": "content_type",
"metadata": "metadata",
"kms_key_name": "kms_key",
"contexts": "contexts",
}


Expand Down
70 changes: 70 additions & 0 deletions packages/google-cloud-storage/google/cloud/storage/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"contentEncoding",
"contentLanguage",
_CONTENT_TYPE_FIELD,
"contexts",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The contexts field should not be added to _READ_ONLY_FIELDS. This list defines properties that are excluded from the payload when calling blob.patch() or blob.update(). Since this PR introduces methods to modify contexts (e.g., set_custom_context), marking it as read-only will prevent these changes from being persisted to the server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules make the required changes mentioned here

"crc32c",
"customTime",
"md5Hash",
Expand Down Expand Up @@ -5008,6 +5009,16 @@ def retention(self):
info = self._properties.get("retention", {})
return Retention.from_api_repr(info, self)

@property
def contexts(self):
"""Retrieve the contexts configuration for this object.

:rtype: :class:`ObjectContexts`
:returns: an instance for managing the object's contexts configuration.
"""
info = self._properties.get("contexts", {})
return ObjectContexts.from_api_repr(info, self)

@property
def soft_delete_time(self):
"""If this object has been soft-deleted, returns the time at which it became soft-deleted.
Expand Down Expand Up @@ -5300,3 +5311,62 @@ def retention_expiration_time(self):
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)


class ObjectContexts(dict):
"""Map an object's contexts configuration.

:type blob: :class:`Blob`
:param blob: blob for which this contexts configuration applies to.
"""

def __init__(self, blob):
super(ObjectContexts, self).__init__({"custom": {}})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This library uses Python 3. Use the more idiomatic super() without arguments instead of the Python 2 style super(ObjectContexts, self).

Suggested change
super(ObjectContexts, self).__init__({"custom": {}})
super().__init__({"custom": {}})
References
  1. Use idiomatic Python 3 super() calls. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jules make these changes

self._blob = blob

@classmethod
def from_api_repr(cls, resource, blob):
"""Factory: construct instance from resource.

:type blob: :class:`Blob`
:param blob: Blob for which this contexts configuration applies to.

:type resource: dict
:param resource: mapping as returned from API call.

:rtype: :class:`ObjectContexts`
:returns: ObjectContexts configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The resource parameter could be None if the contexts property is missing or explicitly null in the API response. Calling .update(None) will raise a TypeError. It is safer to check if resource is truthy before attempting to update the instance.

Suggested change
instance.update(resource)
if resource:
instance.update(resource)
References
  1. When a function receives parameters of an unsupported type, it should handle them safely or raise an error to ensure fail-fast behavior.

return instance

@property
def blob(self):
"""Blob for which this contexts configuration applies to.

:rtype: :class:`Blob`
:returns: the instance's blob.
"""
return self._blob

def set_custom_context(self, key, value):
"""Sets a custom context key-value pair.

:type key: str
:param key: The key of the custom context.

:type value: str
:param value: The value of the custom context.
"""
self.setdefault("custom", {})[key] = {"value": value}
self.blob._patch_property("contexts", self)

def delete_custom_context(self, key):
"""Deletes a custom context key-value pair.

:type key: str
:param key: The key of the custom context to delete.
"""
self.setdefault("custom", {})[key] = {"delete": True}
self.blob._patch_property("contexts", self)
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,7 @@ def list_blobs(
include_folders_as_prefixes=None,
soft_deleted=None,
page_size=None,
filter=None,
):
"""Return an iterator used to find blobs in the bucket.

Expand Down Expand Up @@ -1521,6 +1522,11 @@ def list_blobs(
(Optional) Maximum number of blobs to return in each page.
Defaults to a value set by the API.

:type filter: str
:param filter:
(Optional) A filter expression used to filter results.
See: https://cloud.google.com/storage/docs/json_api/v1/objects/list#filter

:rtype: :class:`~google.api_core.page_iterator.Iterator`
:returns: Iterator of all :class:`~google.cloud.storage.blob.Blob`
in this bucket matching the arguments.
Expand All @@ -1545,6 +1551,7 @@ def list_blobs(
match_glob=match_glob,
include_folders_as_prefixes=include_folders_as_prefixes,
soft_deleted=soft_deleted,
filter=filter,
)

def list_notifications(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,7 @@ def list_blobs(
match_glob=None,
include_folders_as_prefixes=None,
soft_deleted=None,
filter=None,
):
"""Return an iterator used to find blobs in the bucket.

Expand Down Expand Up @@ -1400,6 +1401,10 @@ def list_blobs(
Note ``soft_deleted`` and ``versions`` cannot be set to True simultaneously. See:
https://cloud.google.com/storage/docs/soft-delete

filter (str):
(Optional) A filter expression used to filter results.
See: https://cloud.google.com/storage/docs/json_api/v1/objects/list#filter

Returns:
Iterator of all :class:`~google.cloud.storage.blob.Blob`
in this bucket matching the arguments. The RPC call
Expand Down Expand Up @@ -1443,6 +1448,9 @@ def list_blobs(
if soft_deleted is not None:
extra_params["softDeleted"] = soft_deleted

if filter is not None:
extra_params["filter"] = filter

if bucket.user_project is not None:
extra_params["userProject"] = bucket.user_project

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import unittest
from unittest import mock


class Test_blob_to_proto(unittest.TestCase):
def _call_fut(self, blob):
from google.cloud.storage._grpc_conversions import blob_to_proto

return blob_to_proto(blob)

def test_w_contexts(self):
from google.cloud.storage.blob import Blob
from google.cloud.storage.bucket import Bucket

blob_name = "blob-name"
bucket_name = "bucket-name"
bucket = mock.Mock(spec=Bucket)
bucket.name = bucket_name
contexts = {"custom": {"foo": {"value": "bar"}}}
blob = mock.Mock(spec=Blob)
blob.name = blob_name
blob.bucket = bucket
blob.content_type = None
blob.metadata = None
blob.kms_key_name = None
blob.contexts = contexts

proto = self._call_fut(blob)

self.assertEqual(proto.name, blob_name)
self.assertEqual(proto.bucket, f"projects/_/buckets/{bucket_name}")
self.assertEqual(proto.contexts.custom["foo"].value, "bar")
45 changes: 45 additions & 0 deletions packages/google-cloud-storage/tests/unit/test_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -6205,6 +6205,51 @@ def test_object_lock_retention_configuration_setter(self):
self.assertIsNone(blob.retention.retain_until_time)
self.assertIn("retention", blob._changes)

def test_contexts_defaults(self):
from google.cloud.storage.blob import ObjectContexts

BLOB_NAME = "blob-name"
BUCKET = object()
blob = self._make_one(BLOB_NAME, bucket=BUCKET)

contexts = blob.contexts

self.assertIsInstance(contexts, ObjectContexts)
self.assertIs(contexts.blob, blob)
self.assertEqual(contexts, {"custom": {}})

def test_contexts_w_entry(self):
from google.cloud.storage.blob import ObjectContexts

properties = {"contexts": {"custom": {"foo": {"value": "bar"}}}}
BLOB_NAME = "blob-name"
BUCKET = object()
blob = self._make_one(BLOB_NAME, bucket=BUCKET, properties=properties)

contexts = blob.contexts

self.assertIsInstance(contexts, ObjectContexts)
self.assertIs(contexts.blob, blob)
self.assertEqual(contexts, properties["contexts"])

def test_contexts_set_custom_context(self):
BLOB_NAME = "blob-name"
bucket = _Bucket()
blob = self._make_one(BLOB_NAME, bucket=bucket)

blob.contexts.set_custom_context("foo", "bar")
self.assertEqual(blob.contexts["custom"]["foo"], {"value": "bar"})
self.assertIn("contexts", blob._changes)

def test_contexts_delete_custom_context(self):
BLOB_NAME = "blob-name"
bucket = _Bucket()
blob = self._make_one(BLOB_NAME, bucket=bucket)

blob.contexts.delete_custom_context("foo")
self.assertEqual(blob.contexts["custom"]["foo"], {"delete": True})
self.assertIn("contexts", blob._changes)


class Test__quote(unittest.TestCase):
@staticmethod
Expand Down
32 changes: 32 additions & 0 deletions packages/google-cloud-storage/tests/unit/test_bucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,7 @@ def test_list_blobs_w_defaults(self):
include_folders_as_prefixes=expected_include_folders_as_prefixes,
soft_deleted=soft_deleted,
page_size=page_size,
filter=None,
)

def test_list_blobs_w_explicit(self):
Expand Down Expand Up @@ -1317,6 +1318,37 @@ def test_list_blobs_w_explicit(self):
include_folders_as_prefixes=expected_include_folders_as_prefixes,
soft_deleted=expected_soft_deleted,
page_size=expected_page_size,
filter=None,
)

def test_list_blobs_w_filter(self):
name = "name"
client = self._make_client()
client.list_blobs = mock.Mock(spec=[])
bucket = self._make_one(client=client, name=name)
filter_expr = 'custom.foo = "bar"'

bucket.list_blobs(filter=filter_expr)

client.list_blobs.assert_called_once_with(
bucket,
max_results=None,
page_token=None,
prefix=None,
delimiter=None,
start_offset=None,
end_offset=None,
include_trailing_delimiter=None,
versions=None,
projection="noAcl",
fields=None,
timeout=self._get_default_timeout(),
retry=DEFAULT_RETRY,
match_glob=None,
include_folders_as_prefixes=None,
soft_deleted=None,
page_size=None,
filter=filter_expr,
)

def test_list_notifications_w_defaults(self):
Expand Down
34 changes: 34 additions & 0 deletions packages/google-cloud-storage/tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2251,6 +2251,40 @@ def test_list_blobs_w_explicit_w_user_project(self):
retry=retry,
)

def test_list_blobs_w_filter(self):
from google.cloud.storage.bucket import _blobs_page_start, _item_to_blob

project = "PROJECT"
bucket_name = "name"
filter_expr = 'custom.foo = "bar"'
credentials = _make_credentials()
client = self._make_one(project=project, credentials=credentials)
client._list_resource = mock.Mock(spec=[])
client._bucket_arg_to_bucket = mock.Mock(spec=[])
bucket = client._bucket_arg_to_bucket.return_value = mock.Mock(
spec=["path", "user_project"],
)
bucket.path = f"/b/{bucket_name}"
bucket.user_project = None

client.list_blobs(bucket_name, filter=filter_expr)

expected_extra_params = {
"projection": "noAcl",
"filter": filter_expr,
}
client._list_resource.assert_called_once_with(
f"/b/{bucket_name}/o",
_item_to_blob,
page_token=None,
max_results=None,
extra_params=expected_extra_params,
page_start=_blobs_page_start,
page_size=None,
timeout=self._get_default_timeout(),
retry=DEFAULT_RETRY,
)

def test_list_buckets_wo_project(self):
from google.cloud.exceptions import BadRequest

Expand Down
Loading