Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions cloudpathlib/azure/azblobclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
except ModuleNotFoundError:
implementation_registry["azure"].dependencies_loaded = False

try:
from azure.identity import DefaultAzureCredential
except ImportError:
DefaultAzureCredential = None


@register_client_class("azure")
class AzureBlobClient(Client):
Expand All @@ -66,20 +71,23 @@ def __init__(
https://docs.microsoft.com/en-us/python/api/azure-storage-blob/azure.storage.blob.blobserviceclient?view=azure-python).
Supports the following authentication methods of `BlobServiceClient`.

- Environment variable `""AZURE_STORAGE_CONNECTION_STRING"` containing connecting string
- Environment variable `AZURE_STORAGE_CONNECTION_STRING` containing connecting string
with account credentials. See [Azure Storage SDK documentation](
https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-python#copy-your-credentials-from-the-azure-portal).
- Environment variable `AZURE_STORAGE_ACCOUNT_URL` containing the account URL. If
`azure-identity` is installed, `DefaultAzureCredential` will be used automatically.
- Connection string via `connection_string`, authenticated either with an embedded SAS
token or with credentials passed to `credentials`.
- Account URL via `account_url`, authenticated either with an embedded SAS token, or with
credentials passed to `credentials`.
credentials passed to `credentials`. If `credential` is not provided and `azure-identity`
is installed, `DefaultAzureCredential` will be used automatically.
- Instantiated and already authenticated [`BlobServiceClient`](
https://docs.microsoft.com/en-us/python/api/azure-storage-blob/azure.storage.blob.blobserviceclient?view=azure-python) or
[`DataLakeServiceClient`](https://learn.microsoft.com/en-us/python/api/azure-storage-file-datalake/azure.storage.filedatalake.datalakeserviceclient).

If multiple methods are used, priority order is reverse of list above (later in list takes
priority). If no methods are used, a [`MissingCredentialsError`][cloudpathlib.exceptions.MissingCredentialsError]
exception will be raised raised.
exception will be raised.

Args:
account_url (Optional[str]): The URL to the blob storage account, optionally
Expand Down Expand Up @@ -117,6 +125,8 @@ def __init__(

if connection_string is None:
connection_string = os.getenv("AZURE_STORAGE_CONNECTION_STRING", None)
if account_url is None:
account_url = os.getenv("AZURE_STORAGE_ACCOUNT_URL", None)

self.data_lake_client: Optional[DataLakeServiceClient] = (
None # only needs to end up being set if HNS is enabled
Expand Down Expand Up @@ -174,6 +184,8 @@ def __init__(
conn_str=connection_string, credential=credential
)
elif account_url is not None:
if credential is None and DefaultAzureCredential is not None:
credential = DefaultAzureCredential()
if ".dfs." in account_url:
self.service_client = BlobServiceClient(
account_url=account_url.replace(".dfs.", ".blob."), credential=credential
Expand Down
1 change: 1 addition & 0 deletions cloudpathlib/local/implementations/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def __init__(self, *args, **kwargs):
kwargs.get("connection_string", None),
kwargs.get("account_url", None),
os.getenv("AZURE_STORAGE_CONNECTION_STRING", None),
os.getenv("AZURE_STORAGE_ACCOUNT_URL", None),
]
super().__init__(*args, **kwargs)

Expand Down
134 changes: 134 additions & 0 deletions tests/test_azure_specific.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from unittest.mock import MagicMock, patch

from azure.core.credentials import AzureNamedKeyCredential
from azure.identity import DefaultAzureCredential
Expand Down Expand Up @@ -39,10 +40,143 @@ def test_azureblobpath_properties(path_class, monkeypatch):
@pytest.mark.parametrize("client_class", [AzureBlobClient, LocalAzureBlobClient])
def test_azureblobpath_nocreds(client_class, monkeypatch):
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", None
)
with pytest.raises(MissingCredentialsError):
client_class()


def test_default_credential_used_with_account_url(monkeypatch):
"""DefaultAzureCredential is used when account_url is provided without credential."""
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)

mock_dac = MagicMock()
mock_dac_class = MagicMock(return_value=mock_dac)
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
)

with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
DataLakeServiceClient, "__init__", return_value=None
) as mock_datalake:
AzureBlobClient(account_url="https://myaccount.blob.core.windows.net")

mock_dac_class.assert_called_once()
mock_blob.assert_called_once_with(
account_url="https://myaccount.blob.core.windows.net", credential=mock_dac
)
mock_datalake.assert_called_once_with(
account_url="https://myaccount.dfs.core.windows.net", credential=mock_dac
)


def test_no_default_credential_when_explicit_credential(monkeypatch):
"""DefaultAzureCredential is NOT used when an explicit credential is provided."""
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)

mock_dac_class = MagicMock()
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
)

explicit_cred = MagicMock()
with patch.object(BlobServiceClient, "__init__", return_value=None), patch.object(
DataLakeServiceClient, "__init__", return_value=None
):
AzureBlobClient(
account_url="https://myaccount.blob.core.windows.net",
credential=explicit_cred,
)

mock_dac_class.assert_not_called()


def test_fallback_when_azure_identity_not_installed(monkeypatch):
"""When azure-identity is not installed, credential=None is passed through."""
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", None
)

with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
DataLakeServiceClient, "__init__", return_value=None
):
AzureBlobClient(account_url="https://myaccount.blob.core.windows.net")

mock_blob.assert_called_once_with(
account_url="https://myaccount.blob.core.windows.net", credential=None
)


def test_account_url_env_var_blob(monkeypatch):
"""AZURE_STORAGE_ACCOUNT_URL env var with .blob. URL creates both clients."""
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.setenv(
"AZURE_STORAGE_ACCOUNT_URL", "https://myaccount.blob.core.windows.net"
)

mock_dac = MagicMock()
mock_dac_class = MagicMock(return_value=mock_dac)
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
)

with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
DataLakeServiceClient, "__init__", return_value=None
) as mock_datalake:
AzureBlobClient()

mock_dac_class.assert_called_once()
mock_blob.assert_called_once_with(
account_url="https://myaccount.blob.core.windows.net", credential=mock_dac
)
mock_datalake.assert_called_once_with(
account_url="https://myaccount.dfs.core.windows.net", credential=mock_dac
)


def test_account_url_env_var_dfs(monkeypatch):
"""AZURE_STORAGE_ACCOUNT_URL env var with .dfs. URL creates both clients."""
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.setenv(
"AZURE_STORAGE_ACCOUNT_URL", "https://myaccount.dfs.core.windows.net"
)

mock_dac = MagicMock()
mock_dac_class = MagicMock(return_value=mock_dac)
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
)

with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
DataLakeServiceClient, "__init__", return_value=None
) as mock_datalake:
AzureBlobClient()

mock_blob.assert_called_once_with(
account_url="https://myaccount.blob.core.windows.net", credential=mock_dac
)
mock_datalake.assert_called_once_with(
account_url="https://myaccount.dfs.core.windows.net", credential=mock_dac
)


def test_missing_creds_error_no_env_vars(monkeypatch):
"""MissingCredentialsError is still raised when nothing is configured."""
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
monkeypatch.setattr(
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", None
)
with pytest.raises(MissingCredentialsError):
AzureBlobClient()


def test_as_url(azure_rigs):
p: AzureBlobPath = azure_rigs.create_cloud_path("dir_0/file0_0.txt")

Expand Down