Repository: AgnostiqHQ/covalent
Pull request #1694 (2023-06-08 20:53:10)

adding azure blob storage file transfer strategy
<!--
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Increment the version number in the /VERSION file. If this is a bugfix, increment the patch version (the rightmost number), for example 0.18.2 becomes 0.18.3. If it's a new feature, increment the minor version (the middle number), for example 0.18.2 becomes 0.19.0.
⚠️ Add a note to /CHANGELOG.md with your version number and the date, summarizing the changes.
⚠️ If your pull request fixes an open issue, please link to the issue.
⚠️ Rebase the latest changes from the develop branch. Assuming origin points to https://github.com/AgnostiqHQ/covalent, you should run git rebase origin/develop.
-->

This pull request adds file transfer support for Azure blob storage.  An example usage is shown below:

```
import covalent as ct 

blob_strategy = ct.fs_strategies.Blob(
    client_id=<sp_client_id>,
    client_secret=<sp_client_secret>,
    tenant_id=<my_tenant_id>,
)

input_file = ct.fs.TransferFromRemote(
    <remote_path>,
    <local_path>,
    strategy=blob_strategy,
)

output_file = ct.fs.TransferToRemote(
    <remote_path>,
    <local_path>,
    strategy=blob_strategy,
)

@ct.electron(files=[input_file, output_file])
def blob_transfer(files):
    import os

    file_path = files[0][1]
    return os.path.exists(file_path)

@ct.lattice
def test_blob_transfers():
    return blob_transfer()

ct.dispatch(test_blob_transfers)()
```

Additionally, if any file source specified in `TransferFromRemote` or `TransferToRemote` ends in a trailing slash (`/`) then it will be recognized as a directory download/upload and handled appropriately.  In order to use this feature, we assume the user has previously created a Blob storage account, a storage container within the storage account, service principal, and credentials for the service principal.  The service principal must have read-write access to the storage container.

EDIT: For Azure Blob URIs, we assume the following standard format:
```
https://<storage_account_name>.blob.core.windows.net/<container_name>/<blob_name>
```

- [x] I have added the tests to cover my changes.
- [x] I have updated the documentation and CHANGELOG accordingly.
- [x] I have read the CONTRIBUTING document.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1694?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) Report
> Merging [#1694](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1694?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) (7c7fa3d) into [develop](https://app.codecov.io/gh/AgnostiqHQ/covalent/commit/30cae96d84bbbe7ab411fdba41ea6219677eb78e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) (30cae96) will **increase** coverage by `0.91%`.
> The diff coverage is `28.23%`.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1694/graphs/tree.svg?width=650&height=150&src=pr&token=YGHCB3DE4P&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ)](https://app.codecov.io/gh/AgnostiqHQ/covalent/pull/1694?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ)

```diff
@@             Coverage Diff             @@
##           develop    #1694      +/-   ##
===========================================
+ Coverage    87.79%   88.71%   +0.91%     
===========================================
  Files          159       84      -75     
  Lines         6801     5165    -1636     
  Branches       151        0     -151     
===========================================
- Hits          5971     4582    -1389     
+ Misses         725      583     -142     
+ Partials       105        0     -105     
```

| Flag | Coverage Δ | | *Carryforward flag |
|---|---|---|---|
| Dispatcher | `87.57% <ø> (ø)` | | Carriedforward from [4eca647](https://app.codecov.io/gh/AgnostiqHQ/covalent/commit/4eca647cbbde7ce160a602f070bf475abdf95c17?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) |
| Functional_Tests | `?` | | |
| SDK | `89.36% <28.23%> (-1.58%)` | :arrow_down: | |
| UI_Frontend | `?` | | |

*This pull request uses carry forward flags. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=AgnostiqHQ) to find out more.

</details>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 57ae701d..c853dbe5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+### Added
+
+- File transfer strategy for Azure blob storage
 
 ### Fixed
 
diff --git a/covalent/_file_transfer/enums.py b/covalent/_file_transfer/enums.py
index 64fad123..a1ba9a51 100644
--- a/covalent/_file_transfer/enums.py
+++ b/covalent/_file_transfer/enums.py
@@ -29,6 +29,7 @@ class Order(str, enum.Enum):
 class FileSchemes(str, enum.Enum):
     File = "file"
     S3 = "s3"
+    Blob = "https"
     Globus = "globus"
     HTTP = "http"
     HTTPS = "https"
diff --git a/covalent/_file_transfer/file.py b/covalent/_file_transfer/file.py
index 872f8706..8faea355 100644
--- a/covalent/_file_transfer/file.py
+++ b/covalent/_file_transfer/file.py
@@ -86,6 +86,7 @@ class File:
     def is_remote(self):
         return self._is_remote or self.scheme in [
             FileSchemes.S3,
+            FileSchemes.Blob,
             FileSchemes.Globus,
             FileSchemes.HTTP,
             FileSchemes.HTTPS,
@@ -131,10 +132,13 @@ class File:
     @staticmethod
     def resolve_scheme(path: str) -> FileSchemes:
         scheme = furl(path).scheme
+        host = furl(path).host
         if scheme == FileSchemes.Globus:
             return FileSchemes.Globus
         if scheme == FileSchemes.S3:
             return FileSchemes.S3
+        if scheme == FileSchemes.Blob and "blob.core.windows.net" in host:
+            return FileSchemes.Blob
         if scheme == FileSchemes.FTP:
             return FileSchemes.FTP
         if scheme == FileSchemes.HTTP:
diff --git a/covalent/_file_transfer/file_transfer.py b/covalent/_file_transfer/file_transfer.py
index 8e8d4e37..9fa04f07 100644
--- a/covalent/_file_transfer/file_transfer.py
+++ b/covalent/_file_transfer/file_transfer.py
@@ -128,9 +128,13 @@ def TransferFromRemote(
         FileTransfer instance with implicit Order.BEFORE enum set and from (source) file marked as remote
     """
     # override is_remote for the case where from_filepath is of a file:// scheme where the file is remote (rsync ssh)
-    from_file = File(from_filepath, is_remote=True)
+    is_dir = from_filepath.endswith("/")
+    from_file = File(from_filepath, is_remote=True, is_dir=is_dir)
+    if is_dir and not to_filepath.endswith("/"):
+        raise ValueError("Please specify a directory path (ending with '/') as the destination.")
+    to_file = File(to_filepath, is_dir=is_dir)
     return FileTransfer(
-        from_file=from_file, to_file=to_filepath, order=Order.BEFORE, strategy=strategy
+        from_file=from_file, to_file=to_file, order=Order.BEFORE, strategy=strategy
     )
 
 
@@ -153,7 +157,10 @@ def TransferToRemote(
         FileTransfer instance with implicit Order.AFTER enum set and to (destination) file marked as remote
     """
     # override is_remote for the case where to_filepath is of a file:// scheme where the file is remote (rsync ssh)
-    to_file = File(to_filepath, is_remote=True)
-    return FileTransfer(
-        from_file=from_filepath, to_file=to_file, order=Order.AFTER, strategy=strategy
-    )
+    is_dir = from_filepath and from_filepath.endswith("/")
+    from_file = File(from_filepath, is_dir=is_dir)
+    if is_dir and not to_filepath.endswith("/"):
+        raise ValueError("Please specify a directory path (ending with '/') as the destination.")
+    to_file = File(to_filepath, is_remote=True, is_dir=is_dir)
+
+    return FileTransfer(from_file=from_file, to_file=to_file, order=Order.AFTER, strategy=strategy)
diff --git a/covalent/_file_transfer/strategies/__init__.py b/covalent/_file_transfer/strategies/__init__.py
index 28a562ba..e0ae22f2 100644
--- a/covalent/_file_transfer/strategies/__init__.py
+++ b/covalent/_file_transfer/strategies/__init__.py
@@ -23,5 +23,10 @@ from .rsync_strategy import Rsync
 
 try:
     from .s3_strategy import S3
-except:
+except:  # pragma: no cover
+    pass
+
+try:
+    from .blob_strategy import Blob
+except:  # pragma: no cover
     pass
diff --git a/covalent/_file_transfer/strategies/blob_strategy.py b/covalent/_file_transfer/strategies/blob_strategy.py
new file mode 100644
index 00000000..1da3366b
--- /dev/null
+++ b/covalent/_file_transfer/strategies/blob_strategy.py
@@ -0,0 +1,212 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+from pathlib import Path
+from typing import Callable, Tuple
+
+from furl import furl
+
+from ..._shared_files import logger
+from .. import File
+from .transfer_strategy_base import FileTransferStrategy
+
+app_log = logger.app_log
+
+
+class Blob(FileTransferStrategy):
+    """Implements FileTransferStrategy class to transfer files to/from Azure Blob Storage.
+
+    Args:
+        client_id: ID of a service principal authorized to perform the transfer
+        client_secret: Corresponding secret key for the service principal credentials
+        tenant_id: The Azure Active Directory tenant ID which owns the cloud resources.
+
+    Attributes:
+        credentials: A tuple containing (client_id, client_secret, tenant_id)
+    """
+
+    def __init__(
+        self,
+        client_id: str = None,
+        client_secret: str = None,
+        tenant_id: str = None,
+    ):
+        self.credentials = (tenant_id, client_id, client_secret)
+
+    def _get_blob_service_client(self, storage_account_url):
+        """Returns the service client object for the Blob storage account.
+
+        Args:
+            storage_account_url: Fully qualified URL of the storage account, e.g., https://account_name.blob.core.windows.net
+
+        Returns:
+            blob_service_client: A client object for the Azure Blob service
+        """
+
+        try:
+            from azure.identity import ClientSecretCredential
+            from azure.storage.blob import BlobServiceClient
+        except ImportError:
+            print(
+                "Missing required dependency. Reinstall package as `pip install covalent[azure]`."
+            )
+            raise
+
+        return BlobServiceClient(
+            account_url=storage_account_url, credential=ClientSecretCredential(*self.credentials)
+        )
+
+    def _parse_blob_uri(self, blob_uri: str) -> Tuple[str, str, str]:
+        """Parses a blob URI and returns the account name, container name, and blob name.
+
+        Args:
+            blob_uri: A URI for an Azure Blob object in the form https://<storage_account_name>.blob.core.windows.net/<container_name>/<blob_name>
+
+        Returns:
+            parsed_uri: A tuple containing the storage account name, container name, and blob name
+        """
+
+        blob_furl = furl(blob_uri)
+
+        storage_account_url = blob_furl.host
+        blob_path = blob_furl.path
+
+        storage_container_name = blob_path.segments[0]
+        base_path = "/".join(blob_path.segments[1:])
+
+        return (storage_account_url, storage_container_name, base_path)
+
+    def _download_file(self, container_client, blob_name: str, destination_path: Path) -> None:
+        """Downloads a single blob to the local filesystem.
+
+        Args:
+            container_client: Azure Blob storage container client object
+            blob_name: Name of the blob object within the scope of the container
+            destination_path: Path on the local filesystem where the file will be saved
+        """
+
+        blob_client = container_client.get_blob_client(blob=blob_name)
+
+        with open(destination_path, "wb") as f:
+            stream = blob_client.download_blob()
+            f.write(stream.readall())
+
+    def download(self, from_file: File, to_file: File = File()) -> Callable:
+        """Download files or the contents of folders from Azure Blob Storage.
+
+        Args:
+            from_file: File object referencing an object in Azure Blob storage
+            to_file: File object referencing a path in the local filesystem
+
+        Returns:
+            callable: Download function that is injected into wrapper_fn
+        """
+
+        from_filepath = from_file.filepath
+        to_filepath = to_file.filepath
+
+        storage_account_url, storage_container_name, base_path = self._parse_blob_uri(
+            from_file.uri
+        )
+
+        app_log.debug(
+            f"Blob download; storage account: {storage_account_url}, from_filepath: {from_filepath}, to_filepath {to_filepath}."
+        )
+
+        def callable():
+            """Download file or directory from an Azure Blob storage container."""
+            from pathlib import Path
+
+            blob_service_client = self._get_blob_service_client(storage_account_url)
+            container_client = blob_service_client.get_container_client(storage_container_name)
+
+            blobs = (
+                container_client.list_blobs(name_starts_with=base_path)
+                if from_file.is_dir
+                else [base_path]
+            )
+            for blob in blobs:
+                blob_name = blob.name if from_file.is_dir else base_path
+                dest_obj_path = (
+                    Path(to_filepath) / Path(blob.name).relative_to(base_path)
+                    if to_file.is_dir
+                    else Path(to_filepath)
+                )
+                dest_obj_path.parent.mkdir(parents=True, exist_ok=True)
+                self._download_file(container_client, blob_name, dest_obj_path)
+
+        return callable
+
+    def _upload_file(self, container_client, file_path: str, dest_obj_path: Path) -> None:
+        """Uploads a single file to Azure Blob storage.
+
+        Args:
+            container_client: Azure Blob storage container client object
+            file_path: Path on the local filesystem to a file that is uploaded
+            dest_obj_path: Path in a blob storage container where the file will be saved
+        """
+
+        with open(file_path, "rb") as f:
+            container_client.upload_blob(name=str(dest_obj_path), data=f, overwrite=True)
+
+    def upload(self, from_file: File, to_file: File = File()) -> Callable:
+        """Upload files or the contents of folders to Azure Blob Storage.
+
+        Args:
+            from_file: File object referencing a path in the local filesystem
+            to_file: File object referencing an object in Azure Blob storage
+
+        Returns:
+            callable: Upload function that is injected into wrapper_fn
+        """
+
+        from_filepath = from_file.filepath
+        to_filepath = to_file.filepath
+
+        storage_account_url, storage_container_name, base_path = self._parse_blob_uri(to_file.uri)
+
+        app_log.debug(
+            f"Blob upload; storage account: {storage_account_url}, from_filepath: {from_filepath}, to_filepath {to_filepath}."
+        )
+
+        def callable():
+            """Upload file or directory to an Azure Blob storage container."""
+            from pathlib import Path
+
+            blob_service_client = self._get_blob_service_client(storage_account_url)
+            container_client = blob_service_client.get_container_client(storage_container_name)
+
+            files = (
+                [path for path in Path(from_filepath).rglob("*") if path.is_file()]
+                if from_file.is_dir
+                else [from_filepath]
+            )
+            for file in files:
+                dest_obj_path = (
+                    Path(base_path) / Path(file).relative_to(from_filepath)
+                    if to_file.is_dir
+                    else Path(base_path)
+                )
+                self._upload_file(container_client, file, dest_obj_path)
+
+        return callable
+
+    def cp(self, from_file: File, to_file: File = File()) -> File:  # pragma: no cover
+        raise NotImplementedError
diff --git a/setup.py b/setup.py
index 0183a57e..2242514a 100644
--- a/setup.py
+++ b/setup.py
@@ -213,6 +213,7 @@ setup_info = {
     "install_requires": required,
     "extras_require": {
         "aws": ["boto3>=1.20.48"],
+        "azure": ["azure-identity>=1.13.0", "azure-storage-blob>=12.16.0"],
         "mysql": ["mysqlclient>=2.1.1"],
         "postgres": ["psycopg2-binary>=2.9.5"],
     },


Test patch:
diff --git a/tests/covalent_tests/file_transfer/file_transfer_test.py b/tests/covalent_tests/file_transfer/file_transfer_test.py
index a25baf33..82e71a66 100644
--- a/tests/covalent_tests/file_transfer/file_transfer_test.py
+++ b/tests/covalent_tests/file_transfer/file_transfer_test.py
@@ -1,9 +1,35 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
 from unittest.mock import Mock
 
 import pytest
 
 from covalent._file_transfer import File
-from covalent._file_transfer.file_transfer import FileTransfer
+from covalent._file_transfer.enums import Order
+from covalent._file_transfer.file_transfer import (
+    FileTransfer,
+    TransferFromRemote,
+    TransferToRemote,
+)
+from covalent._file_transfer.strategies.rsync_strategy import Rsync
 
 
 class TestFileTransfer:
@@ -41,3 +67,49 @@ class TestFileTransfer:
             mock_strategy.download.assert_called_once()
         elif not is_from_file_remote and is_to_file_remote:
             mock_strategy.upload.assert_called_once()
+
+    def test_transfer_from_remote(self):
+        strategy = Rsync()
+        result = TransferFromRemote(
+            "file:///home/one.csv", "file:///home/one.csv", strategy=strategy
+        )
+        assert result.from_file.is_remote
+        assert not result.from_file.is_dir
+        assert not result.to_file.is_remote
+        assert not result.to_file.is_dir
+        assert result.order == Order.BEFORE
+        assert result.strategy == strategy
+
+        result = TransferFromRemote("file:///home/one/", "file:///home/one/", strategy=strategy)
+        assert result.from_file.is_remote
+        assert result.from_file.is_dir
+        assert not result.to_file.is_remote
+        assert result.to_file.is_dir
+        assert result.order == Order.BEFORE
+        assert result.strategy == strategy
+
+        with pytest.raises(ValueError):
+            result = TransferFromRemote("file:///home/one/", "file:///home/one", strategy=strategy)
+
+    def test_transfer_to_remote(self):
+        strategy = Rsync()
+        result = TransferToRemote(
+            "file:///home/one.csv", "file:///home/one.csv", strategy=strategy
+        )
+        assert not result.from_file.is_remote
+        assert not result.from_file.is_dir
+        assert result.to_file.is_remote
+        assert not result.to_file.is_dir
+        assert result.order == Order.AFTER
+        assert result.strategy == strategy
+
+        result = TransferToRemote("file:///home/one/", "file:///home/one/", strategy=strategy)
+        assert not result.from_file.is_remote
+        assert result.from_file.is_dir
+        assert result.to_file.is_remote
+        assert result.to_file.is_dir
+        assert result.order == Order.AFTER
+        assert result.strategy == strategy
+
+        with pytest.raises(ValueError):
+            result = TransferToRemote("file:///home/one", "file:///home/one/", strategy=strategy)
diff --git a/tests/covalent_tests/file_transfer/strategies/blob_strategy_test.py b/tests/covalent_tests/file_transfer/strategies/blob_strategy_test.py
new file mode 100644
index 00000000..1459ea69
--- /dev/null
+++ b/tests/covalent_tests/file_transfer/strategies/blob_strategy_test.py
@@ -0,0 +1,182 @@
+# Copyright 2021 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, mock_open
+
+import pytest
+
+from covalent._file_transfer import File
+from covalent._file_transfer.strategies.blob_strategy import Blob
+
+MOCK_LOCAL_FILEPATH = "/Users/user/data.csv"
+MOCK_BLOB_STORAGE_ACCOUNT_URL = "mock-storage-acct.blob.core.windows.net"
+MOCK_BLOB_CONTAINER = "mock-container"
+MOCK_BLOB_NAME = "covalent-tmp/data.csv"
+MOCK_REMOTE_FILEPATH = (
+    f"https://{MOCK_BLOB_STORAGE_ACCOUNT_URL}/{MOCK_BLOB_CONTAINER}/{MOCK_BLOB_NAME}"
+)
+MOCK_CLIENT_ID = "mock-client-id"
+MOCK_CLIENT_SECRET = "mock-secret"
+MOCK_TENANT_ID = "mock-tenant-id"
+
+
+@pytest.fixture
+def blob_strategy():
+    return Blob(MOCK_CLIENT_ID, MOCK_CLIENT_SECRET, MOCK_TENANT_ID)
+
+
+def test_init(mocker, blob_strategy):
+    assert blob_strategy.credentials == (MOCK_TENANT_ID, MOCK_CLIENT_ID, MOCK_CLIENT_SECRET)
+
+
+def test_get_blob_client(mocker, blob_strategy):
+    azure_mock = MagicMock()
+    azure_identity_mock = MagicMock()
+    azure_storage_blob_mock = MagicMock()
+
+    sys.modules["azure"] = azure_mock
+    sys.modules["azure.identity"] = azure_identity_mock
+    sys.modules["azure.storage.blob"] = azure_storage_blob_mock
+
+    credential_mock = azure_identity_mock.ClientSecretCredential
+    credential_mock.return_value = MagicMock()
+
+    blob_service_client_mock = azure_storage_blob_mock.BlobServiceClient
+    blob_service_client_mock.return_value = MagicMock()
+
+    response = blob_strategy._get_blob_service_client(MOCK_BLOB_STORAGE_ACCOUNT_URL)
+    assert response == blob_service_client_mock.return_value
+
+    credential_mock.assert_called_once_with(*blob_strategy.credentials)
+    blob_service_client_mock.assert_called_once_with(
+        account_url=MOCK_BLOB_STORAGE_ACCOUNT_URL, credential=credential_mock.return_value
+    )
+
+    sys.modules["azure.storage.blob"] = None
+    with pytest.raises(ImportError):
+        blob_strategy._get_blob_service_client(MOCK_BLOB_STORAGE_ACCOUNT_URL)
+
+    sys.modules["azure.identity"] = None
+    with pytest.raises(ImportError):
+        blob_strategy._get_blob_service_client(MOCK_BLOB_STORAGE_ACCOUNT_URL)
+
+
+def test_parse_blob_uri(blob_strategy):
+    result = blob_strategy._parse_blob_uri(MOCK_REMOTE_FILEPATH)
+    print(result)
+
+    assert result[0] == MOCK_BLOB_STORAGE_ACCOUNT_URL
+    assert result[1] == MOCK_BLOB_CONTAINER
+    assert result[2] == MOCK_BLOB_NAME
+
+
+def test_download_file(mocker, blob_strategy):
+    container_client_mock = MagicMock()
+
+    blob_client_mock = container_client_mock.get_blob_client
+    blob_client_mock.return_value = MagicMock()
+
+    download_mock = blob_client_mock().download_blob
+    stream_mock = MagicMock()
+    download_mock.return_value = stream_mock
+
+    readall_mock = stream_mock.readall
+    readall_mock().return_value = MagicMock()
+
+    open_mock = mocker.patch("covalent._file_transfer.strategies.blob_strategy.open", mock_open())
+
+    blob_strategy._download_file(container_client_mock, MOCK_BLOB_NAME, MOCK_LOCAL_FILEPATH)
+
+    blob_client_mock.assert_called_with(blob=MOCK_BLOB_NAME)
+    open_mock.assert_called_once_with(MOCK_LOCAL_FILEPATH, "wb")
+    open_mock().write.assert_called_once_with(readall_mock.return_value)
+    download_mock.assert_called_once()
+
+
+def test_download(mocker, blob_strategy):
+    from_file = File(MOCK_REMOTE_FILEPATH)
+    to_file = File(MOCK_LOCAL_FILEPATH)
+
+    mocker.patch("covalent._file_transfer.strategies.blob_strategy.app_log")
+
+    blob_service_client_mock = MagicMock()
+    mocker.patch(
+        "covalent._file_transfer.strategies.blob_strategy.Blob._get_blob_service_client",
+        blob_service_client_mock,
+    )
+    container_client_mock = blob_service_client_mock().get_container_client
+    container_client_mock.return_value = MagicMock()
+
+    mkdir_mock = mocker.patch("pathlib.Path.mkdir")
+
+    download_file_mock = MagicMock()
+    mocker.patch(
+        "covalent._file_transfer.strategies.blob_strategy.Blob._download_file", download_file_mock
+    )
+
+    blob_strategy.download(from_file, to_file)()
+
+    blob_service_client_mock.assert_called_with(MOCK_BLOB_STORAGE_ACCOUNT_URL)
+    container_client_mock.assert_called_with(MOCK_BLOB_CONTAINER)
+    mkdir_mock.assert_any_call(parents=True, exist_ok=True)
+    download_file_mock.assert_any_call(
+        container_client_mock.return_value, MOCK_BLOB_NAME, Path(MOCK_LOCAL_FILEPATH)
+    )
+
+
+def test_upload_file(mocker, blob_strategy):
+    container_client_mock = MagicMock()
+    open_mock = mocker.patch("covalent._file_transfer.strategies.blob_strategy.open", mock_open())
+    upload_mock = container_client_mock.upload_blob
+
+    blob_strategy._upload_file(container_client_mock, MOCK_LOCAL_FILEPATH, MOCK_BLOB_NAME)
+
+    open_mock.assert_called_once_with(MOCK_LOCAL_FILEPATH, "rb")
+    upload_mock.assert_called_once_with(name=MOCK_BLOB_NAME, data=open_mock(), overwrite=True)
+
+
+def test_upload(mocker, blob_strategy):
+    from_file = File(MOCK_LOCAL_FILEPATH)
+    to_file = File(MOCK_REMOTE_FILEPATH)
+
+    mocker.patch("covalent._file_transfer.strategies.blob_strategy.app_log")
+
+    blob_service_client_mock = MagicMock()
+    mocker.patch(
+        "covalent._file_transfer.strategies.blob_strategy.Blob._get_blob_service_client",
+        blob_service_client_mock,
+    )
+    container_client_mock = blob_service_client_mock().get_container_client
+    container_client_mock.return_value = MagicMock()
+
+    upload_file_mock = MagicMock()
+    mocker.patch(
+        "covalent._file_transfer.strategies.blob_strategy.Blob._upload_file", upload_file_mock
+    )
+
+    blob_strategy.upload(from_file, to_file)()
+
+    blob_service_client_mock.assert_called_with(MOCK_BLOB_STORAGE_ACCOUNT_URL)
+    container_client_mock.assert_called_with(MOCK_BLOB_CONTAINER)
+    upload_file_mock.assert_any_call(
+        container_client_mock.return_value, MOCK_LOCAL_FILEPATH, Path(MOCK_BLOB_NAME)
+    )