From fafa0b7b20c7bc68119db5acf0cf6cbf70bb971d Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Fri, 27 Feb 2026 22:27:08 +0100 Subject: [PATCH] Use DefaultAzureCredential by default when no explicit credential is provided When `account_url` is provided without `credential`, automatically use `DefaultAzureCredential` from `azure-identity` if installed, bringing Azure auth in line with how `GSClient` uses `google.auth.default()`. Also adds support for `AZURE_STORAGE_ACCOUNT_URL` env var as a fallback. Closes #497 Co-Authored-By: Claude Opus 4.6 --- cloudpathlib/azure/azblobclient.py | 18 ++- cloudpathlib/local/implementations/azure.py | 1 + tests/test_azure_specific.py | 134 ++++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) diff --git a/cloudpathlib/azure/azblobclient.py b/cloudpathlib/azure/azblobclient.py index 60bd01d3..2510c38e 100644 --- a/cloudpathlib/azure/azblobclient.py +++ b/cloudpathlib/azure/azblobclient.py @@ -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): @@ -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 @@ -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 @@ -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 diff --git a/cloudpathlib/local/implementations/azure.py b/cloudpathlib/local/implementations/azure.py index 2b44814f..f7940153 100644 --- a/cloudpathlib/local/implementations/azure.py +++ b/cloudpathlib/local/implementations/azure.py @@ -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) diff --git a/tests/test_azure_specific.py b/tests/test_azure_specific.py index 142730b4..e044b971 100644 --- a/tests/test_azure_specific.py +++ b/tests/test_azure_specific.py @@ -1,4 +1,5 @@ import os +from unittest.mock import MagicMock, patch from azure.core.credentials import AzureNamedKeyCredential from azure.identity import DefaultAzureCredential @@ -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")