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
53 changes: 53 additions & 0 deletions google/cloud/storage/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -4765,6 +4768,56 @@ 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())
else:
self._patch_property("contexts", value)

@property
def metadata(self):
"""Retrieve arbitrary/application specific metadata for the object.
Expand Down
125 changes: 125 additions & 0 deletions google/cloud/storage/contexts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# 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()
else:
custom_resource[key] = payload
resource[_CUSTOM] = custom_resource
return resource
49 changes: 49 additions & 0 deletions tests/system/test_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,55 @@ 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
contexts2 = ObjectContexts()
contexts2.custom = {
"context_key_1": ObjectCustomContextPayload(value="updated_value"),
"context_key_2": None,
}
blob.contexts = contexts2
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,
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/test_blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -5712,6 +5712,66 @@ 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_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()
Expand Down