Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: support object retention lock by cojenco · Pull Request #1188 · googleapis/python-storage · GitHub
Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Merged
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
20 changes: 20 additions & 0 deletions google/cloud/storage/_helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,6 +290,7 @@ def patch(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all changed properties in a PATCH request.

Expand DownExpand Up@@ -326,12 +327,21 @@ def patch(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)
query_params = self._query_params
# Pass '?projection=full' here because 'PATCH' documented not
# to work properly w/ 'noAcl'.
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand DownExpand Up@@ -361,6 +371,7 @@ def update(
if_metageneration_not_match=None,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY_IF_METAGENERATION_SPECIFIED,
override_unlocked_retention=False,
):
"""Sends all properties in a PUT request.

Expand DownExpand Up@@ -397,11 +408,20 @@ def update(
:type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
:param retry:
(Optional) How to retry the RPC. See: :ref:`configuring_retries`

:type override_unlocked_retention: bool
:param override_unlocked_retention:
(Optional) override_unlocked_retention must be set to True if the operation includes
a retention property that changes the mode from Unlocked to Locked, reduces the
retainUntilTime, or removes the retention configuration from the object. See:
https://cloud.google.com/storage/docs/json_api/v1/objects/patch
"""
client = self._require_client(client)

query_params = self._query_params
query_params["projection"] = "full"
if override_unlocked_retention:
query_params["overrideUnlockedRetention"] = override_unlocked_retention
_add_generation_match_parameters(
query_params,
if_generation_match=if_generation_match,
Expand Down
135 changes: 135 additions & 0 deletions google/cloud/storage/blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,6 +102,7 @@
"md5Hash",
"metadata",
"name",
"retention",
"storageClass",
)
_READ_LESS_THAN_SIZE = (
Expand DownExpand Up@@ -1700,6 +1701,7 @@ def _get_writable_metadata(self):
* ``md5Hash``
* ``metadata``
* ``name``
* ``retention``
* ``storageClass``

For now, we don't support ``acl``, access control lists should be
Expand DownExpand Up@@ -4667,6 +4669,16 @@ def custom_time(self, value):

self._patch_property("customTime", value)

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

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


def _get_host_name(connection):
"""Returns the host name from the given connection.
Expand DownExpand Up@@ -4797,3 +4809,126 @@ def _add_query_parameters(base_url, name_value_pairs):
query = parse_qsl(query)
query.extend(name_value_pairs)
return urlunsplit((scheme, netloc, path, urlencode(query), frag))


class Retention(dict):
"""Map an object's retention configuration.

:type blob: :class:`Blob`
Comment thread
cojenco marked this conversation as resolved.
:params blob: blob for which this retention configuration applies to.

:type mode: str or ``NoneType``
:params mode:
(Optional) The mode of the retention configuration, which can be either Unlocked or Locked.
See: https://cloud.google.com/storage/docs/object-lock

:type retain_until_time: :class:`datetime.datetime` or ``NoneType``
:params retain_until_time:
(Optional) The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:type retention_expiration_time: :class:`datetime.datetime` or ``NoneType``
:params retention_expiration_time:
(Optional) The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for the bucket
that contains the object. This value should normally only be set by the back-end API.
"""

def __init__(
self,
blob,
mode=None,
retain_until_time=None,
retention_expiration_time=None,
):
data = {"mode": mode}
if retain_until_time is not None:
retain_until_time = _datetime_to_rfc3339(retain_until_time)
data["retainUntilTime"] = retain_until_time

if retention_expiration_time is not None:
retention_expiration_time = _datetime_to_rfc3339(retention_expiration_time)
data["retentionExpirationTime"] = retention_expiration_time

super(Retention, self).__init__(data)
self._blob = blob

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

:type blob: :class:`Blob`
:params blob: Blob for which this retention configuration applies to.

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

:rtype: :class:`Retention`
:returns: Retention configuration created from resource.
"""
instance = cls(blob)
instance.update(resource)
return instance

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

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

@property
def mode(self):
"""The mode of the retention configuration. Options are 'Unlocked' or 'Locked'.

:rtype: string
:returns: The mode of the retention configuration, which can be either set to 'Unlocked' or 'Locked'.
"""
return self.get("mode")

@mode.setter
def mode(self, value):
self["mode"] = value
self.blob._patch_property("retention", self)

@property
def retain_until_time(self):
"""The earliest time that the object can be deleted or replaced, which is the
retention configuration set for this object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns: Datetime object parsed from RFC3339 valid timestamp, or
``None`` if the blob's resource has not been loaded from
the server (see :meth:`reload`).
"""
value = self.get("retainUntilTime")
if value is not None:
return _rfc3339_nanos_to_datetime(value)

@retain_until_time.setter
def retain_until_time(self, value):
"""Set the retain_until_time for the object retention configuration.

:type value: :class:`datetime.datetime`
:param value: The earliest time that the object can be deleted or replaced.
"""
if value is not None:
value = _datetime_to_rfc3339(value)
self["retainUntilTime"] = value
self.blob._patch_property("retention", self)

@property
def retention_expiration_time(self):
"""The earliest time that the object can be deleted, which depends on any
retention configuration set for the object and any retention policy set for
the bucket that contains the object.

:rtype: :class:`datetime.datetime` or ``NoneType``
:returns:
(readonly) The earliest time that the object can be deleted.
"""
retention_expiration_time = self.get("retentionExpirationTime")
if retention_expiration_time is not None:
return _rfc3339_nanos_to_datetime(retention_expiration_time)
19 changes: 19 additions & 0 deletions google/cloud/storage/bucket.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,7 @@ def create(
location=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -956,6 +957,11 @@ def create(
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl

:type enable_object_retention: bool
:param enable_object_retention:
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock

:type timeout: float or tuple
:param timeout:
(Optional) The amount of time, in seconds, to wait
Expand All@@ -974,6 +980,7 @@ def create(
location=location,
predefined_acl=predefined_acl,
predefined_default_object_acl=predefined_default_object_acl,
enable_object_retention=enable_object_retention,
timeout=timeout,
retry=retry,
)
Expand DownExpand Up@@ -2750,6 +2757,18 @@ def autoclass_terminal_storage_class_update_time(self):
if timestamp is not None:
return _rfc3339_nanos_to_datetime(timestamp)

@property
def object_retention_mode(self):
"""Retrieve the object retention mode set on the bucket.

:rtype: str
:returns: When set to Enabled, retention configurations can be
set on objects in the bucket.
"""
object_retention = self._properties.get("objectRetention")
if object_retention is not None:
return object_retention.get("mode")

def configure_website(self, main_page_suffix=None, not_found_page=None):
"""Configure website-related properties.

Expand Down
7 changes: 7 additions & 0 deletions google/cloud/storage/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -845,6 +845,7 @@ def create_bucket(
data_locations=None,
predefined_acl=None,
predefined_default_object_acl=None,
enable_object_retention=False,
timeout=_DEFAULT_TIMEOUT,
retry=DEFAULT_RETRY,
):
Expand DownExpand Up@@ -883,6 +884,9 @@ def create_bucket(
predefined_default_object_acl (str):
(Optional) Name of predefined ACL to apply to bucket's objects. See:
https://cloud.google.com/storage/docs/access-control/lists#predefined-acl
enable_object_retention (bool):
(Optional) Whether object retention should be enabled on this bucket. See:
https://cloud.google.com/storage/docs/object-lock
timeout (Optional[Union[float, Tuple[float, float]]]):
The amount of time, in seconds, to wait for the server response.

Expand DownExpand Up@@ -951,6 +955,9 @@ def create_bucket(
if user_project is not None:
query_params["userProject"] = user_project

if enable_object_retention:
query_params["enableObjectRetention"] = enable_object_retention

properties = {key: bucket._properties[key] for key in bucket._changes}
properties["name"] = bucket.name

Expand Down
29 changes: 29 additions & 0 deletions tests/system/test_blob.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -1117,3 +1117,32 @@ def test_blob_update_storage_class_large_file(
blob.update_storage_class(constants.COLDLINE_STORAGE_CLASS)
blob.reload()
assert blob.storage_class == constants.COLDLINE_STORAGE_CLASS


def test_object_retention_lock(storage_client, buckets_to_delete, blobs_to_delete):
# Test bucket created with object retention enabled
new_bucket_name = _helpers.unique_name("object-retention")
created_bucket = _helpers.retry_429_503(storage_client.create_bucket)(
new_bucket_name, enable_object_retention=True
)
buckets_to_delete.append(created_bucket)
assert created_bucket.object_retention_mode == "Enabled"

# Test create object with object retention enabled
payload = b"Hello World"
mode = "Unlocked"
current_time = datetime.datetime.utcnow()
expiration_time = current_time + datetime.timedelta(seconds=10)
blob = created_bucket.blob("object-retention-lock")
blob.retention.mode = mode
blob.retention.retain_until_time = expiration_time
blob.upload_from_string(payload)
blobs_to_delete.append(blob)
blob.reload()
assert blob.retention.mode == mode

# Test patch object to disable object retention
blob.retention.mode = None
blob.retention.retain_until_time = None
blob.patch(override_unlocked_retention=True)
assert blob.retention.mode is None
6 changes: 6 additions & 0 deletions tests/unit/test__helpers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,12 +353,14 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
retry = mock.Mock(spec=[])
generation_number = 9
metageneration_number = 6
override_unlocked_retention = True

derived.patch(
if_generation_match=generation_number,
if_metageneration_match=metageneration_number,
timeout=timeout,
retry=retry,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -370,6 +372,7 @@ def test_patch_w_metageneration_match_w_timeout_w_retry(self):
"projection": "full",
"ifGenerationMatch": generation_number,
"ifMetagenerationMatch": metageneration_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._patch_resource.assert_called_once_with(
path,
Expand DownExpand Up@@ -454,10 +457,12 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
client = derived.client = mock.Mock(spec=["_put_resource"])
client._put_resource.return_value = api_response
timeout = 42
override_unlocked_retention = True

derived.update(
if_metageneration_not_match=generation_number,
timeout=timeout,
override_unlocked_retention=override_unlocked_retention,
)

self.assertEqual(derived._properties, {"foo": "Foo"})
Expand All@@ -467,6 +472,7 @@ def test_update_with_metageneration_not_match_w_timeout_w_retry(self):
expected_query_params = {
"projection": "full",
"ifMetagenerationNotMatch": generation_number,
"overrideUnlockedRetention": override_unlocked_retention,
}
client._put_resource.assert_called_once_with(
path,
Expand Down
Loading