From e8d8b4f3636879fad63a343203ace8f124d1dbb4 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Tue, 4 May 2021 20:14:58 +0100 Subject: [PATCH 01/23] IMP: Allow Datafile to remember where in the cloud it came from --- octue/resources/datafile.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 22792854b..f4fad83e4 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -184,16 +184,20 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa datafile._cloud_metadata = metadata datafile._cloud_metadata["project_name"] = project_name + datafile._cloud_metadata["bucket_name"] = bucket_name + datafile._cloud_metadata["path_in_bucket"] = datafile_path return datafile - def to_cloud(self, project_name, bucket_name, path_in_bucket): + def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): """Upload a datafile to Google Cloud Storage. - :param str project_name: - :param str bucket_name: - :param str path_in_bucket: + :param str|None project_name: + :param str|None bucket_name: + :param str|None path_in_bucket: :return str: gs:// path for datafile """ + project_name, bucket_name, path_in_bucket = self._get_cloud_location(project_name, bucket_name, path_in_bucket) + GoogleCloudStorageClient(project_name=project_name).upload_file( local_path=self.get_local_path(), bucket_name=bucket_name, @@ -282,6 +286,19 @@ def _calculate_hash(self): return super()._calculate_hash(hash) + def _get_cloud_location(self, project_name=None, bucket_name=None, path_in_bucket=None): + """Get the cloud location details for the bucket, allowing the keyword arguments to override any stored values. + + :param str project_name: + :param str bucket_name: + :param str path_in_bucket: + :return (str, str, str): + """ + project_name = project_name or self._cloud_metadata["project_name"] + bucket_name = bucket_name or self._cloud_metadata["bucket_name"] + path_in_bucket = path_in_bucket or self._cloud_metadata["path_in_bucket"] + return project_name, bucket_name, path_in_bucket + def check(self, size_bytes=None, sha=None, last_modified=None, extension=None): """Check file presence and integrity""" # TODO Check consistency of size_bytes input against self.size_bytes property for a file if we have one @@ -353,10 +370,7 @@ def __exit__(obj, *args): obj.fp.close() if datafile.is_in_cloud and any(character in obj.mode for character in {"w", "a", "x", "+", "U"}): - datafile.to_cloud( - datafile._cloud_metadata["project_name"], - *storage.path.split_bucket_name_from_gs_path(datafile.absolute_path), - ) + datafile.to_cloud() return DataFileContextManager From c3fb7b60f8e873e4caa823d554b5b1a2af216acb Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Tue, 4 May 2021 20:18:09 +0100 Subject: [PATCH 02/23] IMP: Add Datafile.update_metadata method --- octue/cloud/storage/client.py | 11 +++++++++++ octue/resources/datafile.py | 22 +++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/octue/cloud/storage/client.py b/octue/cloud/storage/client.py index 9d984c587..8569e49c3 100644 --- a/octue/cloud/storage/client.py +++ b/octue/cloud/storage/client.py @@ -83,6 +83,17 @@ def upload_from_string(self, string, bucket_name, path_in_bucket, metadata=None, self._update_metadata(blob, metadata) logger.info("Uploaded data to Google Cloud at %r.", blob.public_url) + def update_metadata(self, bucket_name, path_in_bucket, metadata): + """Update the metadata for the given cloud file. + + :param str bucket_name: + :param str path_in_bucket: + :param dict metadata: + :return None: + """ + blob = self._blob(bucket_name, path_in_bucket) + self._update_metadata(blob, metadata) + def download_to_file(self, bucket_name, path_in_bucket, local_path, timeout=_DEFAULT_TIMEOUT): """Download a file to a file from a Google Cloud bucket at gs:///. diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index f4fad83e4..880b9d2c3 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -207,6 +207,22 @@ def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): return storage.path.generate_gs_path(bucket_name, path_in_bucket) + def update_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): + """Update the metadata for the datafile in the cloud. + + :param str|None project_name: + :param str|None bucket_name: + :param str|None path_in_bucket: + :return None: + """ + project_name, bucket_name, path_in_bucket = self._get_cloud_location(project_name, bucket_name, path_in_bucket) + + GoogleCloudStorageClient(project_name=project_name).update_metadata( + bucket_name=bucket_name, + path_in_bucket=path_in_bucket, + metadata=self.metadata(), + ) + @property def name(self): return self._name or str(os.path.split(self.path)[-1]) @@ -289,9 +305,9 @@ def _calculate_hash(self): def _get_cloud_location(self, project_name=None, bucket_name=None, path_in_bucket=None): """Get the cloud location details for the bucket, allowing the keyword arguments to override any stored values. - :param str project_name: - :param str bucket_name: - :param str path_in_bucket: + :param str|None project_name: + :param str|None bucket_name: + :param str|None path_in_bucket: :return (str, str, str): """ project_name = project_name or self._cloud_metadata["project_name"] From 521f6c4a32c053a3b5fcad17e48f44fd21c64b83 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 13:19:01 +0100 Subject: [PATCH 03/23] IMP: Raise error if implicit cloud location is missing from Datafile --- octue/exceptions.py | 6 ++++++ octue/resources/datafile.py | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/octue/exceptions.py b/octue/exceptions.py index 61b943ecf..09afba66c 100644 --- a/octue/exceptions.py +++ b/octue/exceptions.py @@ -82,3 +82,9 @@ class AttributeConflict(OctueSDKException): class MissingServiceID(OctueSDKException): """Raise when a specific ID for a service is expected to be provided, but is missing or None.""" + + +class CloudLocationNotSpecified(OctueSDKException): + """Raise when attempting to interact with a cloud resource implicitly but the implicit details of its location are + missing. + """ diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index c6c97e04b..80611cf89 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -7,7 +7,7 @@ from octue.cloud import storage from octue.cloud.storage import GoogleCloudStorageClient from octue.cloud.storage.path import CLOUD_STORAGE_PROTOCOL -from octue.exceptions import AttributeConflict, FileNotFoundException, InvalidInputException +from octue.exceptions import AttributeConflict, CloudLocationNotSpecified, FileNotFoundException, InvalidInputException from octue.mixins import Filterable, Hashable, Identifiable, Loggable, Pathable, Serialisable, Taggable from octue.mixins.hashable import EMPTY_STRING_HASH_VALUE from octue.utils import isfile @@ -344,11 +344,20 @@ def _get_cloud_location(self, project_name=None, bucket_name=None, path_in_bucke :param str|None project_name: :param str|None bucket_name: :param str|None path_in_bucket: + :raise octue.exceptions.CloudLocationNotSpecified: if an exact cloud location isn't provided and isn't available + implicitly (i.e. the Datafile wasn't loaded from the cloud previously) :return (str, str, str): """ - project_name = project_name or self._cloud_metadata["project_name"] - bucket_name = bucket_name or self._cloud_metadata["bucket_name"] - path_in_bucket = path_in_bucket or self._cloud_metadata["path_in_bucket"] + try: + project_name = project_name or self._cloud_metadata["project_name"] + bucket_name = bucket_name or self._cloud_metadata["bucket_name"] + path_in_bucket = path_in_bucket or self._cloud_metadata["path_in_bucket"] + except KeyError: + raise CloudLocationNotSpecified( + f"{self!r} wasn't previously loaded from the cloud so doesn't have an implicit cloud location - please" + f"specify its exact location (its project_name, bucket_name, and path_in_bucket)." + ) + return project_name, bucket_name, path_in_bucket def check(self, size_bytes=None, sha=None, last_modified=None, extension=None): From 7446c8b2ac65288357f8ed3d7fdffdfbb9871a17 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 13:53:53 +0100 Subject: [PATCH 04/23] IMP: Add Datafile._store_cloud_location method and use in cloud methods --- octue/resources/datafile.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 80611cf89..b2aed6100 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -192,9 +192,7 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa ) datafile._cloud_metadata = metadata - datafile._cloud_metadata["project_name"] = project_name - datafile._cloud_metadata["bucket_name"] = bucket_name - datafile._cloud_metadata["path_in_bucket"] = datafile_path + datafile._store_cloud_location(project_name, bucket_name, datafile_path) return datafile def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): @@ -214,9 +212,10 @@ def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): metadata=self.metadata(), ) + self._store_cloud_location(project_name, bucket_name, path_in_bucket) return storage.path.generate_gs_path(bucket_name, path_in_bucket) - def update_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): + def update_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): """Update the metadata for the datafile in the cloud. :param str|None project_name: @@ -232,6 +231,8 @@ def update_metadata(self, project_name=None, bucket_name=None, path_in_bucket=No metadata=self.metadata(), ) + self._store_cloud_location(project_name, bucket_name, path_in_bucket) + @property def name(self): return self._name or str(os.path.split(self.path)[-1]) @@ -360,6 +361,18 @@ def _get_cloud_location(self, project_name=None, bucket_name=None, path_in_bucke return project_name, bucket_name, path_in_bucket + def _store_cloud_location(self, project_name, bucket_name, path_in_bucket): + """Store the cloud location of the datafile. + + :param str project_name: + :param str bucket_name: + :param str path_in_bucket: + :return None: + """ + self._cloud_metadata["project_name"] = project_name + self._cloud_metadata["bucket_name"] = bucket_name + self._cloud_metadata["path_in_bucket"] = path_in_bucket + def check(self, size_bytes=None, sha=None, last_modified=None, extension=None): """Check file presence and integrity""" # TODO Check consistency of size_bytes input against self.size_bytes property for a file if we have one From a98bbc07da867b7b0512329afd22efc6c30e90ac Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 13:57:19 +0100 Subject: [PATCH 05/23] TST: Test Datafile cloud functions with/without implicit cloud locations --- tests/resources/test_datafile.py | 76 ++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index d5a3600a9..c020444c9 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -267,6 +267,82 @@ def test_to_cloud_updates_cloud_files(self): 3, ) + def test_to_cloud_raises_error_if_no_cloud_location_provided_and_datafile_not_from_cloud(self): + """Test that trying to send a datafile to the cloud with no cloud location provided when the datafile was not + constructed from a cloud file results in cloud location error. + """ + datafile = Datafile(path="hello.txt", timestamp=None) + + with self.assertRaises(exceptions.CloudLocationNotSpecified): + datafile.to_cloud() + + def test_to_cloud_works_with_implicit_cloud_location_if_cloud_location_previously_provided(self): + """Test datafile.to_cloud works with an implicit cloud location if the cloud location has previously been + provided. + """ + with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: + temporary_file.write("glib") + + Datafile(timestamp=None, path=temporary_file.name).to_cloud( + project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="glib.txt" + ) + + new_datafile = Datafile.from_cloud( + project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="glib.txt" + ) + + new_datafile.to_cloud() + + def test_update_cloud_metadata(self): + """Test that a cloud datafile's metadata can be updated.""" + with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: + temporary_file.write("glib") + + Datafile(timestamp=None, path=temporary_file.name).to_cloud( + project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="glib.txt" + ) + + new_datafile = Datafile(path="glib.txt", timestamp=None, cluster=32) + new_datafile.update_cloud_metadata( + project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="glib.txt" + ) + + self.assertEqual( + Datafile.from_cloud( + project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="glib.txt" + ).cluster, + 32, + ) + + def test_update_cloud_metadata_works_with_implicit_cloud_location_if_cloud_location_previously_provided(self): + """Test that datafile.update_metadata works with an implicit cloud location if the cloud location has been + previously provided. + """ + with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: + temporary_file.write("blib") + + datafile = Datafile(timestamp=None, path=temporary_file.name) + datafile.to_cloud(project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="blib.txt") + + datafile.cluster = 32 + datafile.update_cloud_metadata() + + self.assertEqual( + Datafile.from_cloud( + project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="blib.txt" + ).cluster, + 32, + ) + + def test_update_cloud_metadata_raises_error_if_no_cloud_location_provided_and_datafile_not_from_cloud(self): + """Test that trying to update a cloud datafile's metadata with no cloud location provided when the datafile was + not constructed from a cloud file results in cloud location error. + """ + datafile = Datafile(path="hello.txt", timestamp=None) + + with self.assertRaises(exceptions.CloudLocationNotSpecified): + datafile.update_cloud_metadata() + def test_get_local_path(self): """Test that a file in the cloud can be temporarily downloaded and its local path returned.""" file_contents = "[1, 2, 3]" From 02819d5acfd1db576e30a3f2350b073c3b227b01 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 15:20:47 +0100 Subject: [PATCH 06/23] TST: Factor out cloud datafile creation in datafile tests --- tests/resources/test_datafile.py | 229 +++++++++++-------------------- 1 file changed, 77 insertions(+), 152 deletions(-) diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index c020444c9..dcc2a8e6f 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -28,6 +28,32 @@ def setUp(self): def create_valid_datafile(self): return Datafile(timestamp=None, path_from=self.path_from, path=self.path, skip_checks=False) + def create_datafile_in_cloud( + self, + project_name=TEST_PROJECT_NAME, + bucket_name=TEST_BUCKET_NAME, + path_in_bucket="cloud_file.txt", + contents="some text", + **kwargs, + ): + """Create a datafile in the cloud. Any metadata attributes can be set via kwargs. + + :param str project_name: + :param str bucket_name: + :param str path_in_bucket: + :param str contents: + :return (str, str, str, str): + """ + with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: + temporary_file.write(contents) + + timestamp = kwargs.pop("timestamp", None) + datafile = Datafile(path=temporary_file.name, timestamp=timestamp, **kwargs) + + datafile.to_cloud(project_name=project_name, bucket_name=bucket_name, path_in_bucket=path_in_bucket) + + return datafile, project_name, bucket_name, path_in_bucket, contents + def test_instantiates(self): """Ensures a Datafile instantiates using only a path and generates a uuid ID""" df = Datafile(timestamp=None, path="a_path") @@ -170,102 +196,69 @@ def test_from_cloud_with_bare_file(self): def test_from_cloud_with_datafile(self): """Test that a Datafile can be constructed from a file on Google Cloud storage with custom metadata.""" - path_in_bucket = "file_to_upload.txt" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("[1, 2, 3]") - - datafile = Datafile( + datafile, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud( timestamp=datetime.now(tz=timezone.utc), - path=temporary_file.name, cluster=0, sequence=1, tags={"blah:shah:nah", "blib", "glib"}, ) - datafile.to_cloud(project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket=path_in_bucket) - persisted_datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path=path_in_bucket - ) + downloaded_datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) - self.assertEqual(persisted_datafile.path, f"gs://{TEST_BUCKET_NAME}/{path_in_bucket}") - self.assertEqual(persisted_datafile.id, datafile.id) - self.assertEqual(persisted_datafile.timestamp, datafile.timestamp) - self.assertEqual(persisted_datafile.hash_value, datafile.hash_value) - self.assertEqual(persisted_datafile.cluster, datafile.cluster) - self.assertEqual(persisted_datafile.sequence, datafile.sequence) - self.assertEqual(persisted_datafile.tags, datafile.tags) - self.assertEqual(persisted_datafile.size_bytes, datafile.size_bytes) - self.assertTrue(isinstance(persisted_datafile._last_modified, float)) + self.assertEqual(downloaded_datafile.path, f"gs://{TEST_BUCKET_NAME}/{path_in_bucket}") + self.assertEqual(downloaded_datafile.id, datafile.id) + self.assertEqual(downloaded_datafile.timestamp, datafile.timestamp) + self.assertEqual(downloaded_datafile.hash_value, datafile.hash_value) + self.assertEqual(downloaded_datafile.cluster, datafile.cluster) + self.assertEqual(downloaded_datafile.sequence, datafile.sequence) + self.assertEqual(downloaded_datafile.tags, datafile.tags) + self.assertEqual(downloaded_datafile.size_bytes, datafile.size_bytes) + self.assertTrue(isinstance(downloaded_datafile._last_modified, float)) def test_from_cloud_with_overwrite(self): """Test that a datafile can be instantiated from the cloud and have its attributes overwritten if new values are given in kwargs. """ - path_in_bucket = "file_to_upload.txt" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("[1, 2, 3]") - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket=path_in_bucket - ) + datafile, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() new_id = str(uuid.uuid4()) - new_datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, - bucket_name=TEST_BUCKET_NAME, + downloaded_datafile = Datafile.from_cloud( + project_name=project_name, + bucket_name=bucket_name, datafile_path=path_in_bucket, allow_overwrite=True, id=new_id, ) - self.assertEqual(new_datafile.id, new_id) + self.assertEqual(downloaded_datafile.id, new_id) + self.assertNotEqual(datafile.id, downloaded_datafile.id) def test_from_cloud_with_overwrite_when_disallowed_results_in_error(self): """Test that attempting to overwrite the attributes of a datafile instantiated from the cloud when not allowed results in an error. """ - path_in_bucket = "my-file.txt" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("[1, 2, 3]") - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket=path_in_bucket - ) + _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() with self.assertRaises(exceptions.AttributeConflict): Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, - bucket_name=TEST_BUCKET_NAME, + project_name=project_name, + bucket_name=bucket_name, datafile_path=path_in_bucket, allow_overwrite=False, id=str(uuid.uuid4()), ) - def test_to_cloud_updates_cloud_files(self): - """Test that calling Datafile.to_cloud on a datafile that is already cloud-based updates it in the cloud.""" - path_in_bucket = "file_to_upload.txt" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("[1, 2, 3]") - - datafile = Datafile(timestamp=None, path=temporary_file.name, cluster=0) - - datafile.to_cloud(project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket=path_in_bucket) + def test_to_cloud_updates_cloud_metadata(self): + """Test that calling Datafile.to_cloud on a datafile that is already cloud-based updates its metadata in the + cloud. + """ + datafile, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud(cluster=0) datafile.cluster = 3 + datafile.to_cloud(project_name, bucket_name, path_in_bucket) - datafile.to_cloud(project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket=path_in_bucket) - - self.assertEqual( - Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path=path_in_bucket - ).cluster, - 3, - ) + self.assertEqual(Datafile.from_cloud(project_name, bucket_name, path_in_bucket).cluster, 3) def test_to_cloud_raises_error_if_no_cloud_location_provided_and_datafile_not_from_cloud(self): """Test that trying to send a datafile to the cloud with no cloud location provided when the datafile was not @@ -280,59 +273,30 @@ def test_to_cloud_works_with_implicit_cloud_location_if_cloud_location_previousl """Test datafile.to_cloud works with an implicit cloud location if the cloud location has previously been provided. """ - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("glib") - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="glib.txt" - ) - - new_datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="glib.txt" - ) - - new_datafile.to_cloud() + _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) + datafile.to_cloud() def test_update_cloud_metadata(self): """Test that a cloud datafile's metadata can be updated.""" - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("glib") - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="glib.txt" - ) + _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() new_datafile = Datafile(path="glib.txt", timestamp=None, cluster=32) - new_datafile.update_cloud_metadata( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="glib.txt" - ) + new_datafile.update_cloud_metadata(project_name, bucket_name, path_in_bucket) - self.assertEqual( - Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="glib.txt" - ).cluster, - 32, - ) + self.assertEqual(Datafile.from_cloud(project_name, bucket_name, path_in_bucket).cluster, 32) def test_update_cloud_metadata_works_with_implicit_cloud_location_if_cloud_location_previously_provided(self): """Test that datafile.update_metadata works with an implicit cloud location if the cloud location has been previously provided. """ - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("blib") - - datafile = Datafile(timestamp=None, path=temporary_file.name) - datafile.to_cloud(project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="blib.txt") + _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) datafile.cluster = 32 datafile.update_cloud_metadata() - self.assertEqual( - Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="blib.txt" - ).cluster, - 32, - ) + self.assertEqual(Datafile.from_cloud(project_name, bucket_name, path_in_bucket).cluster, 32) def test_update_cloud_metadata_raises_error_if_no_cloud_location_provided_and_datafile_not_from_cloud(self): """Test that trying to update a cloud datafile's metadata with no cloud location provided when the datafile was @@ -345,34 +309,16 @@ def test_update_cloud_metadata_raises_error_if_no_cloud_location_provided_and_da def test_get_local_path(self): """Test that a file in the cloud can be temporarily downloaded and its local path returned.""" - file_contents = "[1, 2, 3]" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write(file_contents) - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="nope.txt" - ) - - datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="nope.txt" - ) + _, project_name, bucket_name, path_in_bucket, contents = self.create_datafile_in_cloud() + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) with open(datafile.get_local_path()) as f: - self.assertEqual(f.read(), file_contents) + self.assertEqual(f.read(), contents) def test_get_local_path_with_cached_file_avoids_downloading_again(self): """Test that attempting to download a cached file avoids downloading it again.""" - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write("[1, 2, 3]") - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="nope.txt" - ) - - datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="nope.txt" - ) + _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) # Download for first time. datafile.get_local_path() @@ -411,37 +357,16 @@ def test_open_with_writing_local_file(self): def test_open_with_reading_cloud_file(self): """Test that a cloud datafile can be opened for reading.""" - file_contents = "[1, 2, 3]" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write(file_contents) - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket="nope.txt" - ) - - datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path="nope.txt" - ) + _, project_name, bucket_name, path_in_bucket, contents = self.create_datafile_in_cloud() + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) with datafile.open() as f: - self.assertEqual(f.read(), file_contents) + self.assertEqual(f.read(), contents) def test_open_with_writing_to_cloud_file(self): """Test that a cloud datafile can be opened for writing and that both the remote and local copies are updated.""" - original_file_contents = "[1, 2, 3]" - filename = "nope.txt" - - with tempfile.NamedTemporaryFile("w", delete=False) as temporary_file: - temporary_file.write(original_file_contents) - - Datafile(timestamp=None, path=temporary_file.name).to_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, path_in_bucket=filename - ) - - datafile = Datafile.from_cloud( - project_name=TEST_PROJECT_NAME, bucket_name=TEST_BUCKET_NAME, datafile_path=filename - ) + _, project_name, bucket_name, path_in_bucket, original_contents = self.create_datafile_in_cloud() + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) new_file_contents = "nanana" @@ -450,16 +375,16 @@ def test_open_with_writing_to_cloud_file(self): # Check that the cloud file isn't updated until the context manager is closed. self.assertEqual( - GoogleCloudStorageClient(project_name=TEST_PROJECT_NAME).download_as_string( - bucket_name=TEST_BUCKET_NAME, path_in_bucket=filename + GoogleCloudStorageClient(project_name=project_name).download_as_string( + bucket_name=bucket_name, path_in_bucket=path_in_bucket ), - original_file_contents, + original_contents, ) # Check that the cloud file has now been updated. self.assertEqual( - GoogleCloudStorageClient(project_name=TEST_PROJECT_NAME).download_as_string( - bucket_name=TEST_BUCKET_NAME, path_in_bucket=filename + GoogleCloudStorageClient(project_name=project_name).download_as_string( + bucket_name=bucket_name, path_in_bucket=path_in_bucket ), new_file_contents, ) From 5d3cfd7d4b3000b6e4c556f312b313fefe571934 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 17:46:17 +0100 Subject: [PATCH 07/23] IMP: Avoid re-uploading Datafile file or metadata if they haven't changed --- octue/cloud/storage/client.py | 7 +- octue/mixins/hashable.py | 18 +++-- octue/mixins/identifiable.py | 51 +++++++----- octue/resources/datafile.py | 130 +++++++++++++++++++------------ tests/resources/test_datafile.py | 18 +++++ 5 files changed, 146 insertions(+), 78 deletions(-) diff --git a/octue/cloud/storage/client.py b/octue/cloud/storage/client.py index 66555c9ab..6c65fc428 100644 --- a/octue/cloud/storage/client.py +++ b/octue/cloud/storage/client.py @@ -129,9 +129,14 @@ def get_metadata(self, bucket_name, path_in_bucket, timeout=_DEFAULT_TIMEOUT): """ bucket = self.client.get_bucket(bucket_or_name=bucket_name) blob = bucket.get_blob(blob_name=self._strip_leading_slash(path_in_bucket), timeout=timeout) + + if blob is None: + return None + metadata = blob._properties - # Get timestamps from blob rather than properties so they are datetime.datetime objects rather than strings. + # Get these attributes from blob rather than properties so they are not incorrectly strings. + metadata["size"] = blob.size metadata["updated"] = blob.updated metadata["timeCreated"] = blob.time_created metadata["timeDeleted"] = blob.time_deleted diff --git a/octue/mixins/hashable.py b/octue/mixins/hashable.py index ae4140805..e29afdd8f 100644 --- a/octue/mixins/hashable.py +++ b/octue/mixins/hashable.py @@ -21,8 +21,8 @@ class Hashable: _ATTRIBUTES_TO_HASH = None _HASH_TYPE = "CRC32C" - def __init__(self, hash_value=None, *args, **kwargs): - self._hash_value = hash_value + def __init__(self, immutable_hash_value=None, *args, **kwargs): + self._immutable_hash_value = immutable_hash_value self._ATTRIBUTES_TO_HASH = self._ATTRIBUTES_TO_HASH or [] super().__init__(*args, **kwargs) @@ -40,11 +40,10 @@ class Holder(cls): @property def hash_value(self): """Get the hash of the instance.""" - if self._hash_value: - return self._hash_value + if self._immutable_hash_value is None: + return self._calculate_hash() - self._hash_value = self._calculate_hash() - return self._hash_value + return self._immutable_hash_value @hash_value.setter def hash_value(self, value): @@ -53,14 +52,17 @@ def hash_value(self, value): :param str value: :return None: """ - self._hash_value = value + if self._immutable_hash_value is not None: + raise ValueError(f"The hash of {self!r} is immutable - hash_value cannot be set.") + + self._immutable_hash_value = value def reset_hash(self): """Reset the hash value to the calculated hash (rather than whatever value has been set). :return None: """ - self._hash_value = self._calculate_hash() + self._immutable_hash_value = None def _calculate_hash(self, hash_=None): """Calculate the hash of the sorted attributes in self._ATTRIBUTES_TO_HASH. If hash_ is not None and is diff --git a/octue/mixins/identifiable.py b/octue/mixins/identifiable.py index f3f06730d..96c9969e6 100644 --- a/octue/mixins/identifiable.py +++ b/octue/mixins/identifiable.py @@ -25,27 +25,7 @@ def __init__(self, *args, id=None, name=None, **kwargs): """Constructor for Identifiable class""" self._name = name super().__init__(*args, **kwargs) - - # Store a boolean record of whether this object was created with a previously-existing uuid or was created new. - self._created = True if id is None else False - - if isinstance(id, uuid.UUID): - # If it's a uuid, stringify it - id = str(id) - - elif isinstance(id, str): - # If it's a string (or something similar which can be converted to UUID) check it's valid - try: - id = str(uuid.UUID(id)) - except ValueError: - raise InvalidInputException(f"Value of id '{id}' is not a valid uuid string or instance of class UUID") - - elif id is not None: - raise InvalidInputException( - f"Value of id '{id}' must be a valid uuid string, an instance of class UUID or None" - ) - - self._id = id or gen_uuid() + self._set_id(id) def __str__(self): return f"{self.__class__.__name__} {self._id}" @@ -60,3 +40,32 @@ def id(self): @property def name(self): return self._name + + def _set_id(self, value): + """Set the ID to the given value. + + :param str|uuid.UUID|None value: + :return None: + """ + # Store a boolean record of whether this object was created with a previously-existing uuid or was created new. + self._created = True if value is None else False + + if isinstance(value, uuid.UUID): + # If it's a uuid, stringify it + value = str(value) + + elif isinstance(value, str): + # If it's a string (or something similar which can be converted to UUID) check it's valid + try: + value = str(uuid.UUID(value)) + except ValueError: + raise InvalidInputException( + f"Value of id '{value}' is not a valid uuid string or instance of class UUID" + ) + + elif value is not None: + raise InvalidInputException( + f"Value of id '{value}' must be a valid uuid string, an instance of class UUID or None" + ) + + self._id = value or gen_uuid() diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index b2aed6100..484e5e238 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -87,7 +87,7 @@ def __init__( super().__init__( id=id, name=kwargs.get("name"), - hash_value=kwargs.get("hash_value"), + immutable_hash_value=kwargs.get("immutable_hash_value"), logger=logger, tags=tags, path=path, @@ -157,41 +157,23 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa kwargs :return Datafile: """ - metadata = GoogleCloudStorageClient(project_name).get_metadata(bucket_name, datafile_path) - custom_metadata = metadata.get("metadata") or {} + datafile = cls(timestamp=None, path=storage.path.generate_gs_path(bucket_name, datafile_path)) + + cloud_metadata = datafile.get_cloud_metadata(project_name, bucket_name, datafile_path) + custom_metadata = cloud_metadata.get("metadata") or {} if not allow_overwrite: - for attribute_name, attribute_value in kwargs.items(): - - if custom_metadata.get(attribute_name) == attribute_value: - continue - - raise AttributeConflict( - f"The value {custom_metadata.get(attribute_name)!r} of the {cls.__name__} attribute " - f"{attribute_name!r} conflicts with the value given in kwargs {attribute_value!r}. If you wish to " - f"overwrite the attribute value, set `allow_overwrite` to `True`." - ) - - cluster = kwargs.get("cluster", custom_metadata.get("cluster", CLUSTER_DEFAULT)) - sequence = kwargs.get("sequence", custom_metadata.get("sequence", SEQUENCE_DEFAULT)) - - if isinstance(cluster, str): - cluster = int(cluster) - - if isinstance(sequence, str): - sequence = int(sequence) - - datafile = cls( - timestamp=kwargs.get("timestamp", metadata.get("customTime")), - id=kwargs.get("id", custom_metadata.get("id", ID_DEFAULT)), - path=storage.path.generate_gs_path(bucket_name, datafile_path), - hash_value=metadata.get("crc32c", EMPTY_STRING_HASH_VALUE), - cluster=cluster, - sequence=sequence, - tags=kwargs.get("tags", custom_metadata.get("tags", TAGS_DEFAULT)), - ) + cls._check_for_attribute_conflict(custom_metadata, **kwargs) + + datafile._set_id(kwargs.get("id", custom_metadata.get("id", ID_DEFAULT))) + datafile.path = storage.path.generate_gs_path(bucket_name, datafile_path) + datafile.timestamp = kwargs.get("timestamp", cloud_metadata.get("customTime")) + datafile.immutable_hash_value = cloud_metadata.get("crc32c", EMPTY_STRING_HASH_VALUE) + datafile.cluster = kwargs.get("cluster", custom_metadata.get("cluster", CLUSTER_DEFAULT)) + datafile.sequence = kwargs.get("sequence", custom_metadata.get("sequence", SEQUENCE_DEFAULT)) + datafile.tags = kwargs.get("tags", custom_metadata.get("tags", TAGS_DEFAULT)) - datafile._cloud_metadata = metadata + datafile._cloud_metadata = cloud_metadata datafile._store_cloud_location(project_name, bucket_name, datafile_path) return datafile @@ -204,19 +186,54 @@ def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): :return str: gs:// path for datafile """ project_name, bucket_name, path_in_bucket = self._get_cloud_location(project_name, bucket_name, path_in_bucket) + storage_client = GoogleCloudStorageClient(project_name=project_name) - GoogleCloudStorageClient(project_name=project_name).upload_file( - local_path=self.get_local_path(), - bucket_name=bucket_name, - path_in_bucket=path_in_bucket, - metadata=self.metadata(), - ) + cloud_metadata = self.get_cloud_metadata(project_name, bucket_name, path_in_bucket) or {} + + # If the datafile's file has been changed locally, overwrite its cloud copy. + if cloud_metadata.get("crc32c") != self.hash_value: + storage_client.upload_file( + local_path=self.get_local_path(), + bucket_name=bucket_name, + path_in_bucket=path_in_bucket, + metadata=self.metadata(), + ) + + # If the datafile's metadata has been changed locally, update the cloud file's metadata. + local_metadata = self.metadata() + timestamp = local_metadata.pop("timestamp") + + if cloud_metadata.get("metadata") != local_metadata or timestamp != cloud_metadata.get("customTime"): + self.update_cloud_metadata(project_name, bucket_name, path_in_bucket) self._store_cloud_location(project_name, bucket_name, path_in_bucket) return storage.path.generate_gs_path(bucket_name, path_in_bucket) + def get_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): + """Get the cloud metadata for the datafile, casting the types of the cluster and sequence fields to integer. + + :param str|None project_name: + :param str|None bucket_name: + :param str|None path_in_bucket: + :return dict: + """ + project_name, bucket_name, path_in_bucket = self._get_cloud_location(project_name, bucket_name, path_in_bucket) + cloud_metadata = GoogleCloudStorageClient(project_name).get_metadata(bucket_name, path_in_bucket) + + if cloud_metadata is None: + return None + + if "metadata" in cloud_metadata: + if cloud_metadata["metadata"]["cluster"] is not None: + cloud_metadata["metadata"]["cluster"] = int(cloud_metadata["metadata"]["cluster"]) + + if cloud_metadata["metadata"]["sequence"] is not None: + cloud_metadata["metadata"]["sequence"] = int(cloud_metadata["metadata"]["sequence"]) + + return cloud_metadata + def update_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): - """Update the metadata for the datafile in the cloud. + """Update the cloud metadata for the datafile. :param str|None project_name: :param str|None bucket_name: @@ -284,12 +301,7 @@ def _last_modified(self): @property def size_bytes(self): if self._path_is_in_google_cloud_storage: - size = self._cloud_metadata.get("size") - - if size is None: - return None - - return int(size) + return self._cloud_metadata.get("size") return os.path.getsize(self.absolute_path) @@ -332,7 +344,7 @@ def _calculate_hash(self): """Calculate the hash of the file.""" hash = Checksum() - with open(self.absolute_path, "rb") as f: + with open(self.get_local_path(), "rb") as f: # Read and update hash value in blocks of 4K. for byte_block in iter(lambda: f.read(4096), b""): hash.update(byte_block) @@ -373,6 +385,25 @@ def _store_cloud_location(self, project_name, bucket_name, path_in_bucket): self._cloud_metadata["bucket_name"] = bucket_name self._cloud_metadata["path_in_bucket"] = path_in_bucket + @classmethod + def _check_for_attribute_conflict(cls, custom_metadata, **kwargs): + """Raise an error if there is a conflict between the custom metadata and the kwargs. + + :param dict custom_metadata: + :raise octue.exceptions.AttributeConflict: if any of the custom metadata conflicts with kwargs + :return None: + """ + for attribute_name, attribute_value in kwargs.items(): + + if custom_metadata.get(attribute_name) == attribute_value: + continue + + raise AttributeConflict( + f"The value {custom_metadata.get(attribute_name)!r} of the {cls.__name__} attribute " + f"{attribute_name!r} conflicts with the value given in kwargs {attribute_value!r}. If you wish to " + f"overwrite the attribute value, set `allow_overwrite` to `True`." + ) + def check(self, size_bytes=None, sha=None, last_modified=None, extension=None): """Check file presence and integrity""" # TODO Check consistency of size_bytes input against self.size_bytes property for a file if we have one @@ -416,6 +447,8 @@ def open(self): datafile = self class DataFileContextManager: + MODIFICATION_MODES = {"w", "a", "x", "+", "U"} + def __init__(obj, mode="r", **kwargs): obj.mode = mode obj.kwargs = kwargs @@ -443,7 +476,8 @@ def __exit__(obj, *args): if obj.fp is not None: obj.fp.close() - if datafile.is_in_cloud and any(character in obj.mode for character in {"w", "a", "x", "+", "U"}): + if datafile.is_in_cloud and any(character in obj.mode for character in obj.MODIFICATION_MODES): + datafile.reset_hash() datafile.to_cloud() return DataFileContextManager diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index dcc2a8e6f..67aab8d83 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -260,6 +260,16 @@ def test_to_cloud_updates_cloud_metadata(self): self.assertEqual(Datafile.from_cloud(project_name, bucket_name, path_in_bucket).cluster, 3) + def test_to_cloud_does_not_try_to_update_metadata_if_no_metadata_change_has_been_made(self): + """Test that Datafile.to_cloud does not try to update cloud metadata if no metadata change has been made.""" + _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud(cluster=0) + + datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) + + with patch("octue.resources.datafile.Datafile.update_cloud_metadata") as mock: + datafile.to_cloud() + self.assertFalse(mock.called) + def test_to_cloud_raises_error_if_no_cloud_location_provided_and_datafile_not_from_cloud(self): """Test that trying to send a datafile to the cloud with no cloud location provided when the datafile was not constructed from a cloud file results in cloud location error. @@ -277,6 +287,14 @@ def test_to_cloud_works_with_implicit_cloud_location_if_cloud_location_previousl datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) datafile.to_cloud() + def test_to_cloud_does_not_try_to_update_file_if_no_change_has_been_made_locally(self): + """Test that Datafile.to_cloud does not try to update cloud file if no change has been made locally.""" + datafile, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud(cluster=0) + + with patch("octue.cloud.storage.client.GoogleCloudStorageClient.upload_file") as mock: + datafile.to_cloud() + self.assertFalse(mock.called) + def test_update_cloud_metadata(self): """Test that a cloud datafile's metadata can be updated.""" _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud() From 957080739dcd11444449dafc30757abcd550b4d7 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 18:17:31 +0100 Subject: [PATCH 08/23] REF: Simplify output of GoogleCloudStorageClient.get_metadata --- octue/cloud/storage/client.py | 22 +++++++++++++--------- octue/resources/datafile.py | 33 +++++++++++++++------------------ 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/octue/cloud/storage/client.py b/octue/cloud/storage/client.py index 6c65fc428..5501593ba 100644 --- a/octue/cloud/storage/client.py +++ b/octue/cloud/storage/client.py @@ -27,6 +27,7 @@ def __init__(self, project_name, credentials=OCTUE_MANAGED_CREDENTIALS): credentials = credentials self.client = storage.Client(project=project_name, credentials=credentials) + self.project_name = project_name def create_bucket(self, name, location=None, allow_existing=False, timeout=_DEFAULT_TIMEOUT): """Create a new bucket. If the bucket already exists, and `allow_existing` is `True`, do nothing; if it is @@ -133,15 +134,18 @@ def get_metadata(self, bucket_name, path_in_bucket, timeout=_DEFAULT_TIMEOUT): if blob is None: return None - metadata = blob._properties - - # Get these attributes from blob rather than properties so they are not incorrectly strings. - metadata["size"] = blob.size - metadata["updated"] = blob.updated - metadata["timeCreated"] = blob.time_created - metadata["timeDeleted"] = blob.time_deleted - metadata["customTime"] = blob.custom_time - return metadata + return { + "custom_metadata": blob.metadata or {}, + "crc32c": blob.crc32c, + "size": blob.size, + "updated": blob.updated, + "time_created": blob.time_created, + "time_deleted": blob.time_deleted, + "custom_time": blob.custom_time, + "project_name": self.project_name, + "bucket_name": bucket_name, + "path_in_bucket": path_in_bucket, + } def delete(self, bucket_name, path_in_bucket, timeout=_DEFAULT_TIMEOUT): """Delete the given file from the given bucket. diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 484e5e238..a1d225890 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -158,23 +158,20 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa :return Datafile: """ datafile = cls(timestamp=None, path=storage.path.generate_gs_path(bucket_name, datafile_path)) - - cloud_metadata = datafile.get_cloud_metadata(project_name, bucket_name, datafile_path) - custom_metadata = cloud_metadata.get("metadata") or {} + datafile.get_cloud_metadata(project_name, bucket_name, datafile_path) + custom_metadata = datafile._cloud_metadata.get("custom_metadata") if not allow_overwrite: cls._check_for_attribute_conflict(custom_metadata, **kwargs) datafile._set_id(kwargs.get("id", custom_metadata.get("id", ID_DEFAULT))) datafile.path = storage.path.generate_gs_path(bucket_name, datafile_path) - datafile.timestamp = kwargs.get("timestamp", cloud_metadata.get("customTime")) - datafile.immutable_hash_value = cloud_metadata.get("crc32c", EMPTY_STRING_HASH_VALUE) + datafile.timestamp = kwargs.get("timestamp", datafile._cloud_metadata.get("custom_time")) + datafile.immutable_hash_value = datafile._cloud_metadata.get("crc32c", EMPTY_STRING_HASH_VALUE) datafile.cluster = kwargs.get("cluster", custom_metadata.get("cluster", CLUSTER_DEFAULT)) datafile.sequence = kwargs.get("sequence", custom_metadata.get("sequence", SEQUENCE_DEFAULT)) datafile.tags = kwargs.get("tags", custom_metadata.get("tags", TAGS_DEFAULT)) - datafile._cloud_metadata = cloud_metadata - datafile._store_cloud_location(project_name, bucket_name, datafile_path) return datafile def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): @@ -186,12 +183,12 @@ def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): :return str: gs:// path for datafile """ project_name, bucket_name, path_in_bucket = self._get_cloud_location(project_name, bucket_name, path_in_bucket) - storage_client = GoogleCloudStorageClient(project_name=project_name) + self.get_cloud_metadata(project_name, bucket_name, path_in_bucket) - cloud_metadata = self.get_cloud_metadata(project_name, bucket_name, path_in_bucket) or {} + storage_client = GoogleCloudStorageClient(project_name=project_name) # If the datafile's file has been changed locally, overwrite its cloud copy. - if cloud_metadata.get("crc32c") != self.hash_value: + if self._cloud_metadata.get("crc32c") != self.hash_value: storage_client.upload_file( local_path=self.get_local_path(), bucket_name=bucket_name, @@ -203,10 +200,11 @@ def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): local_metadata = self.metadata() timestamp = local_metadata.pop("timestamp") - if cloud_metadata.get("metadata") != local_metadata or timestamp != cloud_metadata.get("customTime"): + if self._cloud_metadata.get("custom_metadata") != local_metadata or timestamp != self._cloud_metadata.get( + "custom_time" + ): self.update_cloud_metadata(project_name, bucket_name, path_in_bucket) - self._store_cloud_location(project_name, bucket_name, path_in_bucket) return storage.path.generate_gs_path(bucket_name, path_in_bucket) def get_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): @@ -223,14 +221,13 @@ def get_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket if cloud_metadata is None: return None - if "metadata" in cloud_metadata: - if cloud_metadata["metadata"]["cluster"] is not None: - cloud_metadata["metadata"]["cluster"] = int(cloud_metadata["metadata"]["cluster"]) + if cloud_metadata["custom_metadata"].get("cluster") is not None: + cloud_metadata["custom_metadata"] = int(cloud_metadata["custom_metadata"]["cluster"]) - if cloud_metadata["metadata"]["sequence"] is not None: - cloud_metadata["metadata"]["sequence"] = int(cloud_metadata["metadata"]["sequence"]) + if cloud_metadata["custom_metadata"].get("sequence") is not None: + cloud_metadata["custom_metadata"]["sequence"] = int(cloud_metadata["custom_metadata"]["sequence"]) - return cloud_metadata + self._cloud_metadata = cloud_metadata def update_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket=None): """Update the cloud metadata for the datafile. From 7a9d9cdd211f20f64bbf40536629230b727e6adc Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 18:24:04 +0100 Subject: [PATCH 09/23] FIX: Add missing dictionary subscription --- octue/resources/datafile.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index a1d225890..8aae7f449 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -221,11 +221,13 @@ def get_cloud_metadata(self, project_name=None, bucket_name=None, path_in_bucket if cloud_metadata is None: return None - if cloud_metadata["custom_metadata"].get("cluster") is not None: - cloud_metadata["custom_metadata"] = int(cloud_metadata["custom_metadata"]["cluster"]) + custom_metadata = cloud_metadata["custom_metadata"] - if cloud_metadata["custom_metadata"].get("sequence") is not None: - cloud_metadata["custom_metadata"]["sequence"] = int(cloud_metadata["custom_metadata"]["sequence"]) + if custom_metadata.get("cluster") is not None: + custom_metadata["cluster"] = int(custom_metadata["cluster"]) + + if custom_metadata.get("sequence") is not None: + custom_metadata["sequence"] = int(custom_metadata["sequence"]) self._cloud_metadata = cloud_metadata From debb757c66f070a302e0d49923fbd3d867c8a534 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 22:05:19 +0100 Subject: [PATCH 10/23] TST: Ensure file cache doesn't leak between tests --- tests/resources/test_datafile.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index 67aab8d83..45325e3f8 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -9,7 +9,7 @@ from octue import exceptions from octue.cloud.storage import GoogleCloudStorageClient from octue.mixins import MixinBase, Pathable -from octue.resources import Datafile +from octue.resources.datafile import TEMPORARY_LOCAL_FILE_CACHE, Datafile from octue.resources.tag import TagSet from tests import TEST_BUCKET_NAME, TEST_PROJECT_NAME from ..base import BaseTestCase @@ -25,6 +25,9 @@ def setUp(self): self.path_from = MyPathable(path=os.path.join(self.data_path, "basic_files", "configuration", "test-dataset")) self.path = os.path.join("path-within-dataset", "a_test_file.csv") + def tearDown(self): + TEMPORARY_LOCAL_FILE_CACHE.clear() + def create_valid_datafile(self): return Datafile(timestamp=None, path_from=self.path_from, path=self.path, skip_checks=False) @@ -49,9 +52,7 @@ def create_datafile_in_cloud( timestamp = kwargs.pop("timestamp", None) datafile = Datafile(path=temporary_file.name, timestamp=timestamp, **kwargs) - datafile.to_cloud(project_name=project_name, bucket_name=bucket_name, path_in_bucket=path_in_bucket) - return datafile, project_name, bucket_name, path_in_bucket, contents def test_instantiates(self): From fdcd715297622cd2b995ed2a28b824c0a6635203 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 22:11:12 +0100 Subject: [PATCH 11/23] IMP: Allow Datafile to be used as a context manager for cloud changes --- octue/resources/datafile.py | 36 +++++++++++++++++++++++++------- tests/resources/test_datafile.py | 21 +++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 8aae7f449..99fdb9a3c 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -82,12 +82,13 @@ def __init__( sequence=SEQUENCE_DEFAULT, tags=TAGS_DEFAULT, skip_checks=True, + mode="r", **kwargs, ): super().__init__( id=id, - name=kwargs.get("name"), - immutable_hash_value=kwargs.get("immutable_hash_value"), + name=kwargs.pop("name", None), + immutable_hash_value=kwargs.pop("immutable_hash_value", None), logger=logger, tags=tags, path=path, @@ -106,6 +107,14 @@ def __init__( self.check(**kwargs) self._cloud_metadata = {} + self._open_attributes = {"mode": mode, **kwargs} + + def __enter__(self): + self._open_context_manager = self.open(**self._open_attributes) + return self, self._open_context_manager.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + self._open_context_manager.__exit__() def __lt__(self, other): if not isinstance(other, Datafile): @@ -142,7 +151,7 @@ def deserialise(cls, serialised_datafile, path_from=None): return datafile @classmethod - def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=False, **kwargs): + def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=False, mode="r", **kwargs): """Instantiate a Datafile from a previously-persisted Datafile in Google Cloud storage. To instantiate a Datafile from a regular file on Google Cloud storage, the usage is the same, but a meaningful value for each of the instantiated Datafile's attributes can be included in the kwargs (a "regular" file is a file that has been @@ -164,13 +173,14 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa if not allow_overwrite: cls._check_for_attribute_conflict(custom_metadata, **kwargs) - datafile._set_id(kwargs.get("id", custom_metadata.get("id", ID_DEFAULT))) + datafile._set_id(kwargs.pop("id", custom_metadata.get("id", ID_DEFAULT))) datafile.path = storage.path.generate_gs_path(bucket_name, datafile_path) - datafile.timestamp = kwargs.get("timestamp", datafile._cloud_metadata.get("custom_time")) + datafile.timestamp = kwargs.pop("timestamp", datafile._cloud_metadata.get("custom_time")) datafile.immutable_hash_value = datafile._cloud_metadata.get("crc32c", EMPTY_STRING_HASH_VALUE) - datafile.cluster = kwargs.get("cluster", custom_metadata.get("cluster", CLUSTER_DEFAULT)) - datafile.sequence = kwargs.get("sequence", custom_metadata.get("sequence", SEQUENCE_DEFAULT)) - datafile.tags = kwargs.get("tags", custom_metadata.get("tags", TAGS_DEFAULT)) + datafile.cluster = kwargs.pop("cluster", custom_metadata.get("cluster", CLUSTER_DEFAULT)) + datafile.sequence = kwargs.pop("sequence", custom_metadata.get("sequence", SEQUENCE_DEFAULT)) + datafile.tags = kwargs.pop("tags", custom_metadata.get("tags", TAGS_DEFAULT)) + datafile._open_attributes = {"mode": mode, **kwargs} return datafile @@ -334,6 +344,16 @@ def get_local_path(self): TEMPORARY_LOCAL_FILE_CACHE[self.absolute_path] = temporary_local_path return temporary_local_path + def clear_from_file_cache(self): + """Clear the datafile from the temporary local file cache, if it is in there. If datafile.get_local_path is + called again and the datafile is a cloud datafile, the file will be re-downloaded to a new temporary local path, + allowing any independent cloud updates to be synced locally. + + :return None: + """ + if self.absolute_path in TEMPORARY_LOCAL_FILE_CACHE: + del TEMPORARY_LOCAL_FILE_CACHE[self.absolute_path] + def _get_extension_from_path(self, path=None): """Gets extension of a file, either from a provided file path or from self.path field""" path = path or self.path diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index 45325e3f8..542c840d5 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -469,3 +469,24 @@ def test_posix_timestamp(self): datafile.timestamp = datetime(1970, 1, 1) self.assertEqual(datafile.posix_timestamp, 0) + + def test_from_datafile_context_manager(self): + """Test that Datafile.from_cloud can be used as a context manager to manage cloud changes.""" + _, project_name, bucket_name, path_in_bucket, original_content = self.create_datafile_in_cloud() + new_contents = "Here is the new content." + self.assertNotEqual(original_content, new_contents) + + with Datafile.from_cloud(project_name, bucket_name, path_in_bucket, mode="w") as (datafile, f): + datafile.add_tags("blue") + f.write(new_contents) + + # Check that the cloud metadata has been updated. + re_downloaded_datafile = Datafile.from_cloud(project_name, bucket_name, path_in_bucket) + self.assertTrue("blue" in re_downloaded_datafile.tags) + + # The file cache must be cleared so the modified cloud file is downloaded. + re_downloaded_datafile.clear_from_file_cache() + + # Check that the cloud file has been updated. + with re_downloaded_datafile.open() as f: + self.assertEqual(f.read(), new_contents) From d27ec461ab625a2ef296966e02bd9febc7b3aa09 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 22:17:03 +0100 Subject: [PATCH 12/23] FIX: Get empty dict if custom metadata empty --- octue/resources/datafile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 99fdb9a3c..4e3e7ecf8 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -168,7 +168,7 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa """ datafile = cls(timestamp=None, path=storage.path.generate_gs_path(bucket_name, datafile_path)) datafile.get_cloud_metadata(project_name, bucket_name, datafile_path) - custom_metadata = datafile._cloud_metadata.get("custom_metadata") + custom_metadata = datafile._cloud_metadata.get("custom_metadata", {}) if not allow_overwrite: cls._check_for_attribute_conflict(custom_metadata, **kwargs) From 265a65819804f48d8cfff2349d7cf92e4cd9c633 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 22:21:32 +0100 Subject: [PATCH 13/23] TST: Test Datafile can be used as context manager for local changes --- tests/resources/test_datafile.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index 542c840d5..20b7506ba 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -470,7 +470,19 @@ def test_posix_timestamp(self): datafile.timestamp = datetime(1970, 1, 1) self.assertEqual(datafile.posix_timestamp, 0) - def test_from_datafile_context_manager(self): + def test_datafile_as_context_manager(self): + """Test that Datafile can be used as a context manager to manage local changes.""" + temporary_file = tempfile.NamedTemporaryFile("w", delete=False) + contents = "Here is the content." + + with Datafile(path=temporary_file.name, timestamp=None, mode="w") as (datafile, f): + f.write(contents) + + # Check that the cloud file has been updated. + with datafile.open() as f: + self.assertEqual(f.read(), contents) + + def test_from_datafile_as_context_manager(self): """Test that Datafile.from_cloud can be used as a context manager to manage cloud changes.""" _, project_name, bucket_name, path_in_bucket, original_content = self.create_datafile_in_cloud() new_contents = "Here is the new content." From d91cc80cfe6ba13a1205615bd764355e734af6b3 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 22:35:35 +0100 Subject: [PATCH 14/23] IMP: Allow option to not update cloud metadata in Datafile cloud methods --- octue/resources/datafile.py | 40 +++++++++++++++++++++----------- tests/resources/test_datafile.py | 13 ++++++++++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 4e3e7ecf8..e4b6d9185 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -151,7 +151,16 @@ def deserialise(cls, serialised_datafile, path_from=None): return datafile @classmethod - def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=False, mode="r", **kwargs): + def from_cloud( + cls, + project_name, + bucket_name, + datafile_path, + allow_overwrite=False, + mode="r", + update_cloud_metadata=True, + **kwargs, + ): """Instantiate a Datafile from a previously-persisted Datafile in Google Cloud storage. To instantiate a Datafile from a regular file on Google Cloud storage, the usage is the same, but a meaningful value for each of the instantiated Datafile's attributes can be included in the kwargs (a "regular" file is a file that has been @@ -164,6 +173,8 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa :param str datafile_path: path to file represented by datafile :param bool allow_overwrite: if `True`, allow attributes of the datafile to be overwritten by values given in kwargs + :param str mode: + :param bool update_cloud_metadata: :return Datafile: """ datafile = cls(timestamp=None, path=storage.path.generate_gs_path(bucket_name, datafile_path)) @@ -180,16 +191,17 @@ def from_cloud(cls, project_name, bucket_name, datafile_path, allow_overwrite=Fa datafile.cluster = kwargs.pop("cluster", custom_metadata.get("cluster", CLUSTER_DEFAULT)) datafile.sequence = kwargs.pop("sequence", custom_metadata.get("sequence", SEQUENCE_DEFAULT)) datafile.tags = kwargs.pop("tags", custom_metadata.get("tags", TAGS_DEFAULT)) - datafile._open_attributes = {"mode": mode, **kwargs} + datafile._open_attributes = {"mode": mode, "update_cloud_metadata": update_cloud_metadata, **kwargs} return datafile - def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): + def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None, update_cloud_metadata=True): """Upload a datafile to Google Cloud Storage. :param str|None project_name: :param str|None bucket_name: :param str|None path_in_bucket: + :param bool update_cloud_metadata: :return str: gs:// path for datafile """ project_name, bucket_name, path_in_bucket = self._get_cloud_location(project_name, bucket_name, path_in_bucket) @@ -206,14 +218,15 @@ def to_cloud(self, project_name=None, bucket_name=None, path_in_bucket=None): metadata=self.metadata(), ) - # If the datafile's metadata has been changed locally, update the cloud file's metadata. - local_metadata = self.metadata() - timestamp = local_metadata.pop("timestamp") + if update_cloud_metadata: + # If the datafile's metadata has been changed locally, update the cloud file's metadata. + local_metadata = self.metadata() + timestamp = local_metadata.pop("timestamp") - if self._cloud_metadata.get("custom_metadata") != local_metadata or timestamp != self._cloud_metadata.get( - "custom_time" - ): - self.update_cloud_metadata(project_name, bucket_name, path_in_bucket) + if self._cloud_metadata.get("custom_metadata") != local_metadata or timestamp != self._cloud_metadata.get( + "custom_time" + ): + self.update_cloud_metadata(project_name, bucket_name, path_in_bucket) return storage.path.generate_gs_path(bucket_name, path_in_bucket) @@ -468,11 +481,12 @@ def open(self): class DataFileContextManager: MODIFICATION_MODES = {"w", "a", "x", "+", "U"} - def __init__(obj, mode="r", **kwargs): + def __init__(obj, mode="r", update_cloud_metadata=True, **kwargs): obj.mode = mode - obj.kwargs = kwargs obj.fp = None obj.path = None + obj._update_cloud_metadata = update_cloud_metadata + obj.kwargs = kwargs def __enter__(obj): """Open the datafile, first downloading it from the cloud if necessary. @@ -497,7 +511,7 @@ def __exit__(obj, *args): if datafile.is_in_cloud and any(character in obj.mode for character in obj.MODIFICATION_MODES): datafile.reset_hash() - datafile.to_cloud() + datafile.to_cloud(update_cloud_metadata=obj._update_cloud_metadata) return DataFileContextManager diff --git a/tests/resources/test_datafile.py b/tests/resources/test_datafile.py index 20b7506ba..d05841c1a 100644 --- a/tests/resources/test_datafile.py +++ b/tests/resources/test_datafile.py @@ -261,7 +261,18 @@ def test_to_cloud_updates_cloud_metadata(self): self.assertEqual(Datafile.from_cloud(project_name, bucket_name, path_in_bucket).cluster, 3) - def test_to_cloud_does_not_try_to_update_metadata_if_no_metadata_change_has_been_made(self): + def test_to_cloud_does_not_update_cloud_metadata_if_update_cloud_metadata_is_false(self): + """Test that calling Datafile.to_cloud with `update_cloud_metadata=False` doesn't update the cloud metadata.""" + datafile, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud(cluster=0) + datafile.cluster = 3 + + with patch("octue.resources.datafile.Datafile.update_cloud_metadata") as mock: + datafile.to_cloud(project_name, bucket_name, path_in_bucket, update_cloud_metadata=False) + self.assertFalse(mock.called) + + self.assertEqual(Datafile.from_cloud(project_name, bucket_name, path_in_bucket).cluster, 0) + + def test_to_cloud_does_not_update_metadata_if_no_metadata_change_has_been_made(self): """Test that Datafile.to_cloud does not try to update cloud metadata if no metadata change has been made.""" _, project_name, bucket_name, path_in_bucket, _ = self.create_datafile_in_cloud(cluster=0) From a81c3a9d7d3c6775b0e83f3a449cf804506f2953 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Wed, 5 May 2021 22:36:31 +0100 Subject: [PATCH 15/23] FIX: Propagate __exit__ exception parameters --- octue/resources/datafile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index e4b6d9185..460be94bc 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -114,7 +114,7 @@ def __enter__(self): return self, self._open_context_manager.__enter__() def __exit__(self, exc_type, exc_val, exc_tb): - self._open_context_manager.__exit__() + self._open_context_manager.__exit__(exc_type, exc_val, exc_tb) def __lt__(self, other): if not isinstance(other, Datafile): From 8b819b99b10e01a766120089b6746772e490d924 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 15:42:21 +0100 Subject: [PATCH 16/23] REF: Rename context manager inside Datafile --- octue/resources/datafile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 0a308feec..7482dee79 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -479,7 +479,7 @@ def open(self): """ datafile = self - class DataFileContextManager: + class DatafileContextManager: MODIFICATION_MODES = {"w", "a", "x", "+", "U"} def __init__(obj, mode="r", update_cloud_metadata=True, **kwargs): @@ -514,7 +514,7 @@ def __exit__(obj, *args): datafile.reset_hash() datafile.to_cloud(update_cloud_metadata=obj._update_cloud_metadata) - return DataFileContextManager + return DatafileContextManager def metadata(self): """Get the datafile's metadata in a serialised form. From 2bc160a289ee42f2574beace9045527d452ea9d4 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 15:57:47 +0100 Subject: [PATCH 17/23] REF: Move DatafileContextManager out of Datafile and make private --- octue/resources/datafile.py | 78 +++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 7482dee79..85e5eed4e 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -1,4 +1,5 @@ import datetime +import functools import logging import os import tempfile @@ -477,44 +478,7 @@ def open(self): fp.write("{}") ``` """ - datafile = self - - class DatafileContextManager: - MODIFICATION_MODES = {"w", "a", "x", "+", "U"} - - def __init__(obj, mode="r", update_cloud_metadata=True, **kwargs): - obj.mode = mode - obj.fp = None - obj.path = None - obj._update_cloud_metadata = update_cloud_metadata - obj.kwargs = kwargs - - def __enter__(obj): - """Open the datafile, first downloading it from the cloud if necessary. - - :return io.TextIOWrapper: - """ - obj.path = datafile.get_local_path() - - if "w" in obj.mode: - os.makedirs(os.path.split(obj.path)[0], exist_ok=True) - - obj.fp = open(obj.path, obj.mode, **obj.kwargs) - return obj.fp - - def __exit__(obj, *args): - """Close the datafile, updating the corresponding file in the cloud if necessary. - - :return None: - """ - if obj.fp is not None: - obj.fp.close() - - if datafile.is_in_cloud and any(character in obj.mode for character in obj.MODIFICATION_MODES): - datafile.reset_hash() - datafile.to_cloud(update_cloud_metadata=obj._update_cloud_metadata) - - return DatafileContextManager + return functools.partial(_DatafileContextManager, self) def metadata(self): """Get the datafile's metadata in a serialised form. @@ -528,3 +492,41 @@ def metadata(self): "sequence": self.sequence, "tags": self.tags.serialise(to_string=True), } + + +class _DatafileContextManager: + MODIFICATION_MODES = {"w", "a", "x", "+", "U"} + + def __init__(self, datafile, mode="r", update_cloud_metadata=True, **kwargs): + self.datafile = datafile + self.mode = mode + self._update_cloud_metadata = update_cloud_metadata + self.kwargs = kwargs + self.fp = None + self.path = None + + def __enter__(self): + """Open the datafile, first downloading it from the cloud if necessary. + + :return io.TextIOWrapper: + """ + self.path = self.datafile.get_local_path() + + if "w" in self.mode: + os.makedirs(os.path.split(self.path)[0], exist_ok=True) + + self.fp = open(self.path, self.mode, **self.kwargs) + return self.fp + + def __exit__(self, *args): + """Close the datafile, updating the corresponding file in the cloud if necessary and its metadata if + required. + + :return None: + """ + if self.fp is not None: + self.fp.close() + + if self.datafile.is_in_cloud and any(character in self.mode for character in self.MODIFICATION_MODES): + self.datafile.reset_hash() + self.datafile.to_cloud(update_cloud_metadata=self._update_cloud_metadata) From 33396e9a44940dbd0e80d4c35f726dc79fdf4131 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 16:02:46 +0100 Subject: [PATCH 18/23] DOC: Add docstring to _DatafileContextManager --- octue/resources/datafile.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index 85e5eed4e..a2b82887a 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -495,6 +495,15 @@ def metadata(self): class _DatafileContextManager: + """A context manager for opening datafiles for reading and writing locally or from the cloud. It is analogous to the + open context manager, but with greater scope. + + :param octue.resources.datafile.Datafile datafile: + :param str mode: + :param bool update_cloud_metadata: + :return None: + """ + MODIFICATION_MODES = {"w", "a", "x", "+", "U"} def __init__(self, datafile, mode="r", update_cloud_metadata=True, **kwargs): From 68be5dd008682fa88c311256aeff5b1df6156ceb Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 16:09:47 +0100 Subject: [PATCH 19/23] IMP: Use hash of local file if cloud datafile's file has been downloaded --- octue/resources/datafile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index a2b82887a..a70880115 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -357,6 +357,9 @@ def get_local_path(self): ) TEMPORARY_LOCAL_FILE_CACHE[self.absolute_path] = temporary_local_path + + # Now use hash value of local file instead of cloud file. + self.reset_hash() return temporary_local_path def clear_from_file_cache(self): @@ -537,5 +540,4 @@ def __exit__(self, *args): self.fp.close() if self.datafile.is_in_cloud and any(character in self.mode for character in self.MODIFICATION_MODES): - self.datafile.reset_hash() self.datafile.to_cloud(update_cloud_metadata=self._update_cloud_metadata) From aa34562aab07a178fb40279e9d839f77af1f86eb Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 16:27:31 +0100 Subject: [PATCH 20/23] DOC: Add more docstrings to datafile module --- octue/resources/datafile.py | 77 ++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/octue/resources/datafile.py b/octue/resources/datafile.py index a70880115..e47245066 100644 --- a/octue/resources/datafile.py +++ b/octue/resources/datafile.py @@ -57,7 +57,10 @@ class Datafile(Taggable, Serialisable, Pathable, Loggable, Identifiable, Hashabl :param int sequence: A sequence number of this file within its cluster (if sequences are appropriate) :param str tags: Space-separated string of tags relevant to this file :param bool skip_checks: - + :param str mode: if using as a context manager, open the datafile for reading/editing in this mode (the mode + options are the same as for the builtin open function) + :param bool update_cloud_metadata: if using as a context manager and this is True, update the cloud metadata of + the datafile when the context is exited :return None: """ @@ -84,6 +87,7 @@ def __init__( tags=TAGS_DEFAULT, skip_checks=True, mode="r", + update_cloud_metadata=True, **kwargs, ): super().__init__( @@ -108,7 +112,7 @@ def __init__( self.check(**kwargs) self._cloud_metadata = {} - self._open_attributes = {"mode": mode, **kwargs} + self._open_attributes = {"mode": mode, "update_cloud_metadata": update_cloud_metadata, **kwargs} def __enter__(self): self._open_context_manager = self.open(**self._open_attributes) @@ -174,8 +178,10 @@ def from_cloud( :param str datafile_path: path to file represented by datafile :param bool allow_overwrite: if `True`, allow attributes of the datafile to be overwritten by values given in kwargs - :param str mode: - :param bool update_cloud_metadata: + :param str mode: if using as a context manager, open the datafile for reading/editing in this mode (the mode + options are the same as for the builtin open function) + :param bool update_cloud_metadata: if using as a context manager and this is True, update the cloud metadata of + the datafile when the context is exited :return Datafile: """ datafile = cls(timestamp=None, path=storage.path.generate_gs_path(bucket_name, datafile_path)) @@ -301,6 +307,10 @@ def timestamp(self, value): @property def posix_timestamp(self): + """Get the timestamp of the datafile in posix format. + + :return float: + """ if self.timestamp is None: return None @@ -462,24 +472,9 @@ def exists(self): @property def open(self): - """Context manager to handle the opening and closing of a Datafile. - - If opened in write mode, the manager will attempt to determine if the folder path exists and, if not, will - create the folder structure required to write the file. - - Use it like: - ``` - my_datafile = Datafile(timestamp=None, path='subfolder/subsubfolder/my_datafile.json) - with my_datafile.open('w') as fp: - fp.write("{}") - ``` - This is equivalent to the standard python: - ``` - my_datafile = Datafile(timestamp=None, path='subfolder/subsubfolder/my_datafile.json) - os.makedirs(os.path.split(my_datafile.absolute_path)[0], exist_ok=True) - with open(my_datafile.absolute_path, 'w') as fp: - fp.write("{}") - ``` + """Get a context manager for handling the opening and closing of the datafile for reading/editing. + + :return type: the class octue.resources.datafile._DatafileContextManager """ return functools.partial(_DatafileContextManager, self) @@ -498,12 +493,30 @@ def metadata(self): class _DatafileContextManager: - """A context manager for opening datafiles for reading and writing locally or from the cloud. It is analogous to the - open context manager, but with greater scope. + """A context manager for opening datafiles for reading and writing locally or from the cloud. Its usage is analogous + to the builtin open context manager. If opening a local datafile in write mode, the manager will attempt to + determine if the folder path exists and, if not, will create the folder structure required to write the file. + + Usage: + ``` + my_datafile = Datafile(timestamp=None, path='subfolder/subsubfolder/my_datafile.json) + with my_datafile.open('w') as fp: + fp.write("{}") + ``` + + This is equivalent to the standard python: + ``` + my_datafile = Datafile(timestamp=None, path='subfolder/subsubfolder/my_datafile.json) + os.makedirs(os.path.split(my_datafile.absolute_path)[0], exist_ok=True) + with open(my_datafile.absolute_path, 'w') as fp: + fp.write("{}") + ``` :param octue.resources.datafile.Datafile datafile: - :param str mode: - :param bool update_cloud_metadata: + :param str mode: open the datafile for reading/editing in this mode (the mode options are the same as for the + builtin open function) + :param bool update_cloud_metadata: this is True, update the cloud metadata of + the datafile when the context is exited :return None: """ @@ -514,7 +527,7 @@ def __init__(self, datafile, mode="r", update_cloud_metadata=True, **kwargs): self.mode = mode self._update_cloud_metadata = update_cloud_metadata self.kwargs = kwargs - self.fp = None + self._fp = None self.path = None def __enter__(self): @@ -527,17 +540,17 @@ def __enter__(self): if "w" in self.mode: os.makedirs(os.path.split(self.path)[0], exist_ok=True) - self.fp = open(self.path, self.mode, **self.kwargs) - return self.fp + self._fp = open(self.path, self.mode, **self.kwargs) + return self._fp def __exit__(self, *args): """Close the datafile, updating the corresponding file in the cloud if necessary and its metadata if - required. + self._update_cloud_metadata is True. :return None: """ - if self.fp is not None: - self.fp.close() + if self._fp is not None: + self._fp.close() if self.datafile.is_in_cloud and any(character in self.mode for character in self.MODIFICATION_MODES): self.datafile.to_cloud(update_cloud_metadata=self._update_cloud_metadata) From f7d3c936757d735bc3ed8ba1c8cd5f0ec8d92a42 Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 18:24:06 +0100 Subject: [PATCH 21/23] DOC: Add documentation on Datafile usages skip_ci_tests --- docs/source/datafile.rst | 128 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/docs/source/datafile.rst b/docs/source/datafile.rst index 915d7d320..73459f37c 100644 --- a/docs/source/datafile.rst +++ b/docs/source/datafile.rst @@ -12,3 +12,131 @@ the following main attributes: - ``sequence`` - a sequence number of this file within its cluster (if sequences are appropriate) - ``tags`` - a space-separated string or iterable of tags relevant to this file - ``timestamp`` - a posix timestamp associated with the file, in seconds since epoch, typically when it was created but could relate to a relevant time point for the data + + +----- +Usage +----- + +``Datafile`` can be used functionally or as a context manager. When used as a context manager, it is analogous to the +builtin ``open`` function context manager. On exiting the context (``with`` block), it closes the datafile locally and, +if it is a cloud datafile, updates the cloud object with any data or metadata changes. + + +Example A +--------- +**Scenario:** Download a cloud object, calculate Octue metadata from its contents, and add the new metadata to the cloud object + +**Starting point:** Object in cloud with or without Octue metadata + +**Goal:** Object in cloud with updated metadata + +.. code-block:: python + + from octue.resources import Datafile + + + project_name = "my-project" + bucket_name = "my-bucket", + datafile_path = "path/to/data.csv" + + with Datafile.from_cloud(project_name, bucket_name, datafile_path, mode="r") as datafile, f: + data = f.read() + new_metadata = metadata_calculating_function(data) + + datafile.timestamp = new_metadata["timestamp"] + datafile.cluster = new_metadata["cluster"] + datafile.sequence = new_metadata["sequence"] + datafile.tags = new_metadata["tags"] + + +Example B +--------- +**Scenario:** Add or update Octue metadata on an existing cloud object *without downloading its content* + +**Starting point:** A cloud object with or without Octue metadata + +**Goal:** Object in cloud with updated metadata + +.. code-block:: python + + from datetime import datetime + from octue.resources import Datafile + + + project_name = "my-project" + bucket_name = "my-bucket" + datafile_path = "path/to/data.csv" + + datafile = Datafile.from_cloud(project_name, bucket_name, datafile_path): + + datafile.timestamp = datetime.now() + datafile.cluster = 0 + datafile.sequence = 3 + datafile.tags = {"manufacturer:Vestas", "output:1MW"} + + datafile.to_cloud() # Or, datafile.update_cloud_metadata() + + +Example C +--------- +**Scenario:** Read in the contents and Octue metadata of an existing cloud object without intent to update it in the cloud + +**Starting point:** A cloud object with or without Octue metadata + +**Goal:** Cloud object data (contents) and metadata held locally in local variables + +.. code-block:: python + + from octue.resources import Datafile + + + project_name = "my-project" + bucket_name = "my-bucket" + datafile_path = "path/to/data.csv" + + datafile = Datafile.from_cloud(project_name, bucket_name, datafile_path) + + with datafile.open("r") as f: + data = f.read() + + metadata = datafile.metadata() + + +Example D +--------- +**Scenario:** Create a new cloud object from local data, adding Octue metadata + +**Starting point:** A file-like locally (or content data in local variable) with Octue metadata stored in local variables + +**Goal:** A new object in the cloud with data and Octue metadata + +For creating new data in a new local file: + +.. code-block:: python + + from octue.resources import Datafile + + + sequence = 2 + tags = {"cleaned:True", "type:linear"} + + + with Datafile(path="path/to/local/file.dat", timestamp=None, sequence=sequence, tags=tags, mode="w") as datafile, f: + f.write("This is some cleaned data.") + + datafile.to_cloud(project_name="my-project", bucket_name="my-bucket", path_in_bucket="path/to/data.dat") + + +For existing data in an existing local file: + +.. code-block:: python + + from octue.resources import Datafile + + + sequence = 2 + tags = {"cleaned:True", "type:linear"} + + datafile = Datafile(path="path/to/local/file.dat", timestamp=None, sequence=sequence, tags=tags) + datafile.to_cloud(project_name="my-project", bucket_name="my-bucket", path_in_bucket="path/to/data.dat") From cfb4022a390cd7bcc1285008bcce35452473174c Mon Sep 17 00:00:00 2001 From: cortadocodes Date: Fri, 7 May 2021 18:50:51 +0100 Subject: [PATCH 22/23] DOC: Update datafile documentation with image and correction --- docs/source/datafile.rst | 5 ++++- docs/source/images/datafile_use_cases.png | Bin 0 -> 83314 bytes 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 docs/source/images/datafile_use_cases.png diff --git a/docs/source/datafile.rst b/docs/source/datafile.rst index 73459f37c..8b323839c 100644 --- a/docs/source/datafile.rst +++ b/docs/source/datafile.rst @@ -23,6 +23,9 @@ builtin ``open`` function context manager. On exiting the context (``with`` bloc if it is a cloud datafile, updates the cloud object with any data or metadata changes. +.. image:: images/datafile_use_cases.png + + Example A --------- **Scenario:** Download a cloud object, calculate Octue metadata from its contents, and add the new metadata to the cloud object @@ -82,7 +85,7 @@ Example C --------- **Scenario:** Read in the contents and Octue metadata of an existing cloud object without intent to update it in the cloud -**Starting point:** A cloud object with or without Octue metadata +**Starting point:** A cloud object with Octue metadata **Goal:** Cloud object data (contents) and metadata held locally in local variables diff --git a/docs/source/images/datafile_use_cases.png b/docs/source/images/datafile_use_cases.png new file mode 100644 index 0000000000000000000000000000000000000000..972e10bd5f450665e1f6548df60e38ccc5002690 GIT binary patch literal 83314 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 Date: Fri, 7 May 2021 18:54:27 +0100 Subject: [PATCH 23/23] TST: Add Hashable immutable hash test --- tests/mixins/test_hashable.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/mixins/test_hashable.py b/tests/mixins/test_hashable.py index 7fae21b73..edfb13924 100644 --- a/tests/mixins/test_hashable.py +++ b/tests/mixins/test_hashable.py @@ -79,3 +79,10 @@ def test_reset_hash(self): type_with_hash.reset_hash() self.assertEqual(type_with_hash.hash_value, original_calculated_hash) + + def test_hash_value_cannot_be_set_if_hashable_has_immutable_hash_value(self): + """Test that the hash value of a hashable instance with an immutable hash value cannot be set.""" + hashable = Hashable(immutable_hash_value="blue") + + with self.assertRaises(ValueError): + hashable.hash_value = "red"