diff --git a/google/cloud/storage/blob.py b/google/cloud/storage/blob.py index 08a86a52d..b8f01f63f 100644 --- a/google/cloud/storage/blob.py +++ b/google/cloud/storage/blob.py @@ -52,6 +52,7 @@ from google.api_core.iam import Policy from google.cloud import exceptions from google.cloud._helpers import _bytes_to_unicode +from google.cloud._helpers import _datetime_to_rfc3339 from google.cloud._helpers import _rfc3339_to_datetime from google.cloud._helpers import _to_bytes from google.cloud.exceptions import NotFound @@ -3348,6 +3349,39 @@ def updated(self): if value is not None: return _rfc3339_to_datetime(value) + @property + def custom_time(self): + """Retrieve the custom time for the object. + + See https://cloud.google.com/storage/docs/json_api/v1/objects + + :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._properties.get("customTime") + if value is not None: + return _rfc3339_to_datetime(value) + + @custom_time.setter + def custom_time(self, value): + """Set the custom time for the object. Once set it can't be unset + and only changed to a custom datetime in the future. If the + custom_time must be unset, you must either perform a rewrite operation + or upload the data again. + + See https://cloud.google.com/storage/docs/json_api/v1/objects + + :type value: :class:`datetime.datetime` + :param value: (Optional) Set the custom time of blob. Datetime object + parsed from RFC3339 valid timestamp. + """ + if value is not None: + value = _datetime_to_rfc3339(value) + + self._properties["customTime"] = value + def _get_encryption_headers(key, source=False): """Builds customer encryption key headers diff --git a/google/cloud/storage/bucket.py b/google/cloud/storage/bucket.py index a0ef863bb..e68703fac 100644 --- a/google/cloud/storage/bucket.py +++ b/google/cloud/storage/bucket.py @@ -170,6 +170,18 @@ class LifecycleRuleConditions(dict): :param number_of_newer_versions: (Optional) Apply rule action to versioned items having N newer versions. + :type days_since_custom_time: int + :param days_since_custom_time: (Optional) Apply rule action to items whose number of days + elapsed since the custom timestamp. This condition is relevant + only for versioned objects. The value of the field must be a non + negative integer. If it's zero, the object version will become + eligible for lifecycle action as soon as it becomes custom. + + :type custom_time_before: :class:`datetime.date` + :param custom_time_before: (Optional) Date object parsed from RFC3339 valid date, apply rule action + to items whose custom time is before this date. This condition is relevant + only for versioned objects, e.g., 2019-03-16. + :type days_since_noncurrent_time: int :param days_since_noncurrent_time: (Optional) Apply rule action to items whose number of days elapsed since the non current timestamp. This condition @@ -193,6 +205,8 @@ def __init__( is_live=None, matches_storage_class=None, number_of_newer_versions=None, + days_since_custom_time=None, + custom_time_before=None, days_since_noncurrent_time=None, noncurrent_time_before=None, _factory=False, @@ -214,6 +228,12 @@ def __init__( if number_of_newer_versions is not None: conditions["numNewerVersions"] = number_of_newer_versions + if days_since_custom_time is not None: + conditions["daysSinceCustomTime"] = days_since_custom_time + + if custom_time_before is not None: + conditions["customTimeBefore"] = custom_time_before.isoformat() + if not _factory and not conditions: raise ValueError("Supply at least one condition") @@ -266,6 +286,18 @@ def number_of_newer_versions(self): """Conditon's 'number_of_newer_versions' value.""" return self.get("numNewerVersions") + @property + def days_since_custom_time(self): + """Conditon's 'days_since_custom_time' value.""" + return self.get("daysSinceCustomTime") + + @property + def custom_time_before(self): + """Conditon's 'custom_time_before' value.""" + before = self.get("customTimeBefore") + if before is not None: + return datetime_helpers.from_iso8601_date(before) + @property def days_since_noncurrent_time(self): """Conditon's 'days_since_noncurrent_time' value.""" diff --git a/tests/system/test_system.py b/tests/system/test_system.py index e92ae3254..7d6e79b07 100644 --- a/tests/system/test_system.py +++ b/tests/system/test_system.py @@ -196,7 +196,9 @@ def test_lifecycle_rules(self): from google.cloud.storage import constants new_bucket_name = "w-lifcycle-rules" + unique_resource_id("-") + custom_time_before = datetime.date(2018, 8, 1) noncurrent_before = datetime.date(2018, 8, 1) + self.assertRaises( exceptions.NotFound, Config.CLIENT.get_bucket, new_bucket_name ) @@ -204,10 +206,11 @@ def test_lifecycle_rules(self): bucket.add_lifecycle_delete_rule( age=42, number_of_newer_versions=3, + days_since_custom_time=2, + custom_time_before=custom_time_before, days_since_noncurrent_time=2, noncurrent_time_before=noncurrent_before, ) - bucket.add_lifecycle_set_storage_class_rule( constants.COLDLINE_STORAGE_CLASS, is_live=False, @@ -218,6 +221,8 @@ def test_lifecycle_rules(self): LifecycleRuleDelete( age=42, number_of_newer_versions=3, + days_since_custom_time=2, + custom_time_before=custom_time_before, days_since_noncurrent_time=2, noncurrent_time_before=noncurrent_before, ), diff --git a/tests/unit/test_blob.py b/tests/unit/test_blob.py index b2589499d..d4b60a28c 100644 --- a/tests/unit/test_blob.py +++ b/tests/unit/test_blob.py @@ -157,6 +157,7 @@ def _set_properties_helper(self, kms_key_name=None): "crc32c": CRC32C, "componentCount": COMPONENT_COUNT, "etag": ETAG, + "customTime": NOW, } if kms_key_name is not None: @@ -188,6 +189,7 @@ def _set_properties_helper(self, kms_key_name=None): self.assertEqual(blob.crc32c, CRC32C) self.assertEqual(blob.component_count, COMPONENT_COUNT) self.assertEqual(blob.etag, ETAG) + self.assertEqual(blob.custom_time, now) if kms_key_name is not None: self.assertEqual(blob.kms_key_name, kms_key_name) @@ -4248,6 +4250,48 @@ def test_updated_unset(self): blob = self._make_one("blob-name", bucket=BUCKET) self.assertIsNone(blob.updated) + def test_custom_time_getter(self): + from google.cloud._helpers import _RFC3339_MICROS + from google.cloud._helpers import UTC + + BLOB_NAME = "blob-name" + bucket = _Bucket() + TIMESTAMP = datetime.datetime(2014, 11, 5, 20, 34, 37, tzinfo=UTC) + TIME_CREATED = TIMESTAMP.strftime(_RFC3339_MICROS) + properties = {"customTime": TIME_CREATED} + blob = self._make_one(BLOB_NAME, bucket=bucket, properties=properties) + self.assertEqual(blob.custom_time, TIMESTAMP) + + def test_custom_time_setter(self): + from google.cloud._helpers import UTC + + BLOB_NAME = "blob-name" + bucket = _Bucket() + TIMESTAMP = datetime.datetime(2014, 11, 5, 20, 34, 37, tzinfo=UTC) + blob = self._make_one(BLOB_NAME, bucket=bucket) + self.assertIsNone(blob.custom_time) + blob.custom_time = TIMESTAMP + self.assertEqual(blob.custom_time, TIMESTAMP) + + def test_custom_time_setter_none_value(self): + from google.cloud._helpers import _RFC3339_MICROS + from google.cloud._helpers import UTC + + BLOB_NAME = "blob-name" + bucket = _Bucket() + TIMESTAMP = datetime.datetime(2014, 11, 5, 20, 34, 37, tzinfo=UTC) + TIME_CREATED = TIMESTAMP.strftime(_RFC3339_MICROS) + properties = {"customTime": TIME_CREATED} + blob = self._make_one(BLOB_NAME, bucket=bucket, properties=properties) + self.assertEqual(blob.custom_time, TIMESTAMP) + blob.custom_time = None + self.assertIsNone(blob.custom_time) + + def test_custom_time_unset(self): + BUCKET = object() + blob = self._make_one("blob-name", bucket=BUCKET) + self.assertIsNone(blob.custom_time) + def test_from_string_w_valid_uri(self): from google.cloud.storage.blob import Blob diff --git a/tests/unit/test_bucket.py b/tests/unit/test_bucket.py index 2336416c4..38a358da4 100644 --- a/tests/unit/test_bucket.py +++ b/tests/unit/test_bucket.py @@ -77,6 +77,8 @@ def test_ctor_w_created_before_and_is_live(self): self.assertEqual(conditions.is_live, False) self.assertIsNone(conditions.matches_storage_class) self.assertIsNone(conditions.number_of_newer_versions) + self.assertIsNone(conditions.days_since_custom_time) + self.assertIsNone(conditions.custom_time_before) self.assertIsNone(conditions.noncurrent_time_before) def test_ctor_w_number_of_newer_versions(self): @@ -89,6 +91,19 @@ def test_ctor_w_number_of_newer_versions(self): self.assertIsNone(conditions.matches_storage_class) self.assertEqual(conditions.number_of_newer_versions, 3) + def test_ctor_w_days_since_custom_time(self): + conditions = self._make_one( + number_of_newer_versions=3, days_since_custom_time=2 + ) + expected = {"numNewerVersions": 3, "daysSinceCustomTime": 2} + self.assertEqual(dict(conditions), expected) + self.assertIsNone(conditions.age) + self.assertIsNone(conditions.created_before) + self.assertIsNone(conditions.is_live) + self.assertIsNone(conditions.matches_storage_class) + self.assertEqual(conditions.number_of_newer_versions, 3) + self.assertEqual(conditions.days_since_custom_time, 2) + def test_ctor_w_days_since_noncurrent_time(self): conditions = self._make_one( number_of_newer_versions=3, days_since_noncurrent_time=2 @@ -102,6 +117,25 @@ def test_ctor_w_days_since_noncurrent_time(self): self.assertEqual(conditions.number_of_newer_versions, 3) self.assertEqual(conditions.days_since_noncurrent_time, 2) + def test_ctor_w_custom_time_before(self): + import datetime + + custom_time_before = datetime.date(2018, 8, 1) + conditions = self._make_one( + number_of_newer_versions=3, custom_time_before=custom_time_before + ) + expected = { + "numNewerVersions": 3, + "customTimeBefore": custom_time_before.isoformat(), + } + self.assertEqual(dict(conditions), expected) + self.assertIsNone(conditions.age) + self.assertIsNone(conditions.created_before) + self.assertIsNone(conditions.is_live) + self.assertIsNone(conditions.matches_storage_class) + self.assertEqual(conditions.number_of_newer_versions, 3) + self.assertEqual(conditions.custom_time_before, custom_time_before) + def test_ctor_w_noncurrent_time_before(self): import datetime @@ -125,16 +159,18 @@ def test_ctor_w_noncurrent_time_before(self): def test_from_api_repr(self): import datetime + custom_time_before = datetime.date(2018, 8, 1) noncurrent_before = datetime.date(2018, 8, 1) before = datetime.date(2018, 8, 1) klass = self._get_target_class() - resource = { "age": 10, "createdBefore": "2018-08-01", "isLive": True, "matchesStorageClass": ["COLDLINE"], "numNewerVersions": 3, + "daysSinceCustomTime": 2, + "customTimeBefore": custom_time_before.isoformat(), "daysSinceNoncurrentTime": 2, "noncurrentTimeBefore": noncurrent_before.isoformat(), } @@ -144,6 +180,8 @@ def test_from_api_repr(self): self.assertEqual(conditions.is_live, True) self.assertEqual(conditions.matches_storage_class, ["COLDLINE"]) self.assertEqual(conditions.number_of_newer_versions, 3) + self.assertEqual(conditions.days_since_custom_time, 2) + self.assertEqual(conditions.custom_time_before, custom_time_before) self.assertEqual(conditions.days_since_noncurrent_time, 2) self.assertEqual(conditions.noncurrent_time_before, noncurrent_before)