From fafa0b7b20c7bc68119db5acf0cf6cbf70bb971d Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Fri, 27 Feb 2026 22:27:08 +0100 Subject: [PATCH 1/7] 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") From 88fd3f9d01d933349c3c130d1a5190d4f0c86dcb Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Mon, 16 Mar 2026 16:58:23 +0100 Subject: [PATCH 2/7] Add azure identity as optional dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 81f3d433..aa7248b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ ] [project.optional-dependencies] -azure = ["azure-storage-blob>=12", "azure-storage-file-datalake>=12"] +azure = ["azure-storage-blob>=12", "azure-storage-file-datalake>=12", "azure-identity>=1"] gs = ["google-cloud-storage"] s3 = ["boto3>=1.34.0"] all = ["cloudpathlib[azure]", "cloudpathlib[gs]", "cloudpathlib[s3]"] From 15e3a5ef3043d48f23d7f8ae14750265a31d1d50 Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Mon, 16 Mar 2026 16:58:46 +0100 Subject: [PATCH 3/7] Add comment why azure.identity import is not in big try-except block --- cloudpathlib/azure/azblobclient.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cloudpathlib/azure/azblobclient.py b/cloudpathlib/azure/azblobclient.py index 2510c38e..af720aec 100644 --- a/cloudpathlib/azure/azblobclient.py +++ b/cloudpathlib/azure/azblobclient.py @@ -42,6 +42,8 @@ except ModuleNotFoundError: implementation_registry["azure"].dependencies_loaded = False +# azure-identity is an additional optional dependency; users can use cloudpathlib's +# Azure functionality without it, but will not get automatic DefaultAzureCredential support. try: from azure.identity import DefaultAzureCredential except ImportError: From ba13261828346e80ffeff9bf03936c64dca13f60 Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Mon, 16 Mar 2026 17:04:15 +0100 Subject: [PATCH 4/7] Refactor tests to use existing mock infrastructure instead of MagicMock Replace unittest.mock.MagicMock/patch patterns with the project's existing MockBlobServiceClient and MockedDataLakeServiceClient, following the same monkeypatch approach used in conftest.py. Tests now verify actual client properties instead of asserting on mock calls. Co-Authored-By: Claude Opus 4.6 --- tests/mock_clients/mock_adls_gen2.py | 8 +- tests/mock_clients/mock_azureblob.py | 8 +- tests/test_azure_specific.py | 117 +++++++++++---------------- 3 files changed, 59 insertions(+), 74 deletions(-) diff --git a/tests/mock_clients/mock_adls_gen2.py b/tests/mock_clients/mock_adls_gen2.py index aaee7cd1..349c3ad0 100644 --- a/tests/mock_clients/mock_adls_gen2.py +++ b/tests/mock_clients/mock_adls_gen2.py @@ -8,7 +8,13 @@ class MockedDataLakeServiceClient: - def __init__(self, test_dir, adls): + def __init__(self, test_dir=None, adls=None, account_url=None, credential=None): + if account_url is not None: + # account_url-based construction: store url and credential for verification + self._account_url = account_url + self._credential = credential + return + # root is parent of the test specific directory self.root = test_dir.parent self.test_dir = test_dir diff --git a/tests/mock_clients/mock_azureblob.py b/tests/mock_clients/mock_azureblob.py index f99e0d4a..f0c56687 100644 --- a/tests/mock_clients/mock_azureblob.py +++ b/tests/mock_clients/mock_azureblob.py @@ -49,7 +49,13 @@ def get(self, key, default=None): class MockBlobServiceClient: - def __init__(self, test_dir, adls): + def __init__(self, test_dir=None, adls=None, account_url=None, credential=None): + if account_url is not None: + # account_url-based construction: store url and credential for verification + self._account_url = account_url + self._credential = credential + return + # copy test assets for reference in tests without affecting assets shutil.copytree(TEST_ASSETS, test_dir, dirs_exist_ok=True) diff --git a/tests/test_azure_specific.py b/tests/test_azure_specific.py index e044b971..de2ec0fd 100644 --- a/tests/test_azure_specific.py +++ b/tests/test_azure_specific.py @@ -1,5 +1,4 @@ import os -from unittest.mock import MagicMock, patch from azure.core.credentials import AzureNamedKeyCredential from azure.identity import DefaultAzureCredential @@ -11,6 +10,7 @@ from azure.storage.filedatalake import DataLakeServiceClient import pytest +import cloudpathlib.azure.azblobclient from urllib.parse import urlparse, parse_qs from cloudpathlib import AzureBlobClient, AzureBlobPath from cloudpathlib.exceptions import ( @@ -20,7 +20,8 @@ ) from cloudpathlib.local import LocalAzureBlobClient, LocalAzureBlobPath -from .mock_clients.mock_azureblob import MockStorageStreamDownloader +from .mock_clients.mock_azureblob import MockBlobServiceClient, MockStorageStreamDownloader +from .mock_clients.mock_adls_gen2 import MockedDataLakeServiceClient @pytest.mark.parametrize("path_class", [AzureBlobPath, LocalAzureBlobPath]) @@ -48,51 +49,47 @@ def test_azureblobpath_nocreds(client_class, monkeypatch): client_class() +def _mock_azure_clients(monkeypatch): + """Monkeypatch BlobServiceClient and DataLakeServiceClient with mocks.""" + monkeypatch.setattr( + cloudpathlib.azure.azblobclient, "BlobServiceClient", MockBlobServiceClient + ) + monkeypatch.setattr( + cloudpathlib.azure.azblobclient, "DataLakeServiceClient", MockedDataLakeServiceClient + ) + + 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_azure_clients(monkeypatch) - mock_dac = MagicMock() - mock_dac_class = MagicMock(return_value=mock_dac) - monkeypatch.setattr( - "cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class - ) + client = AzureBlobClient(account_url="https://myaccount.blob.core.windows.net") - 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") + assert isinstance(client.service_client, MockBlobServiceClient) + assert client.service_client._account_url == "https://myaccount.blob.core.windows.net" + assert isinstance(client.service_client._credential, DefaultAzureCredential) - 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 - ) + assert isinstance(client.data_lake_client, MockedDataLakeServiceClient) + assert client.data_lake_client._account_url == "https://myaccount.dfs.core.windows.net" + assert isinstance(client.data_lake_client._credential, DefaultAzureCredential) 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_azure_clients(monkeypatch) - mock_dac_class = MagicMock() - monkeypatch.setattr( - "cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class + explicit_cred = "my-explicit-credential" + client = AzureBlobClient( + account_url="https://myaccount.blob.core.windows.net", + credential=explicit_cred, ) - 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() + assert client.service_client._credential == explicit_cred + assert not isinstance(client.service_client._credential, DefaultAzureCredential) def test_fallback_when_azure_identity_not_installed(monkeypatch): @@ -100,17 +97,13 @@ def test_fallback_when_azure_identity_not_installed(monkeypatch): monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False) monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False) monkeypatch.setattr( - "cloudpathlib.azure.azblobclient.DefaultAzureCredential", None + cloudpathlib.azure.azblobclient, "DefaultAzureCredential", None ) + _mock_azure_clients(monkeypatch) - 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") + client = 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 - ) + assert client.service_client._credential is None def test_account_url_env_var_blob(monkeypatch): @@ -119,25 +112,17 @@ def test_account_url_env_var_blob(monkeypatch): monkeypatch.setenv( "AZURE_STORAGE_ACCOUNT_URL", "https://myaccount.blob.core.windows.net" ) + _mock_azure_clients(monkeypatch) - mock_dac = MagicMock() - mock_dac_class = MagicMock(return_value=mock_dac) - monkeypatch.setattr( - "cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class - ) + client = AzureBlobClient() - with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object( - DataLakeServiceClient, "__init__", return_value=None - ) as mock_datalake: - AzureBlobClient() + assert isinstance(client.service_client, MockBlobServiceClient) + assert client.service_client._account_url == "https://myaccount.blob.core.windows.net" + assert isinstance(client.service_client._credential, DefaultAzureCredential) - 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 - ) + assert isinstance(client.data_lake_client, MockedDataLakeServiceClient) + assert client.data_lake_client._account_url == "https://myaccount.dfs.core.windows.net" + assert isinstance(client.data_lake_client._credential, DefaultAzureCredential) def test_account_url_env_var_dfs(monkeypatch): @@ -146,24 +131,12 @@ def test_account_url_env_var_dfs(monkeypatch): monkeypatch.setenv( "AZURE_STORAGE_ACCOUNT_URL", "https://myaccount.dfs.core.windows.net" ) + _mock_azure_clients(monkeypatch) - 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() + client = 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 - ) + assert client.service_client._account_url == "https://myaccount.blob.core.windows.net" + assert client.data_lake_client._account_url == "https://myaccount.dfs.core.windows.net" def test_missing_creds_error_no_env_vars(monkeypatch): @@ -171,7 +144,7 @@ def test_missing_creds_error_no_env_vars(monkeypatch): monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False) monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False) monkeypatch.setattr( - "cloudpathlib.azure.azblobclient.DefaultAzureCredential", None + cloudpathlib.azure.azblobclient, "DefaultAzureCredential", None ) with pytest.raises(MissingCredentialsError): AzureBlobClient() From 544094a4838134da8196d32b203c6f30dca480ab Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Tue, 17 Mar 2026 08:50:05 +0100 Subject: [PATCH 5/7] Move azure-identity import into main dependency block Per reviewer feedback, azure-identity is no longer treated as a separate optional dependency. It's imported alongside the other Azure SDK packages and falls under the same dependencies_loaded check. Co-Authored-By: Claude Opus 4.6 --- cloudpathlib/azure/azblobclient.py | 19 +++++++------------ tests/test_azure_specific.py | 20 -------------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/cloudpathlib/azure/azblobclient.py b/cloudpathlib/azure/azblobclient.py index af720aec..ab61fe75 100644 --- a/cloudpathlib/azure/azblobclient.py +++ b/cloudpathlib/azure/azblobclient.py @@ -39,16 +39,11 @@ SharedKeyCredentialPolicy as DataLakeSharedKeyCredentialPolicy, ) + from azure.identity import DefaultAzureCredential + except ModuleNotFoundError: implementation_registry["azure"].dependencies_loaded = False -# azure-identity is an additional optional dependency; users can use cloudpathlib's -# Azure functionality without it, but will not get automatic DefaultAzureCredential support. -try: - from azure.identity import DefaultAzureCredential -except ImportError: - DefaultAzureCredential = None - @register_client_class("azure") class AzureBlobClient(Client): @@ -76,13 +71,13 @@ def __init__( - 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. + - Environment variable `AZURE_STORAGE_ACCOUNT_URL` containing the account URL. + `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`. If `credential` is not provided and `azure-identity` - is installed, `DefaultAzureCredential` will be used automatically. + credentials passed to `credentials`. If `credential` is not provided, + `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). @@ -186,7 +181,7 @@ def __init__( conn_str=connection_string, credential=credential ) elif account_url is not None: - if credential is None and DefaultAzureCredential is not None: + if credential is None: credential = DefaultAzureCredential() if ".dfs." in account_url: self.service_client = BlobServiceClient( diff --git a/tests/test_azure_specific.py b/tests/test_azure_specific.py index de2ec0fd..0dd30105 100644 --- a/tests/test_azure_specific.py +++ b/tests/test_azure_specific.py @@ -42,9 +42,6 @@ def test_azureblobpath_properties(path_class, monkeypatch): 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() @@ -92,20 +89,6 @@ def test_no_default_credential_when_explicit_credential(monkeypatch): assert not isinstance(client.service_client._credential, DefaultAzureCredential) -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 - ) - _mock_azure_clients(monkeypatch) - - client = AzureBlobClient(account_url="https://myaccount.blob.core.windows.net") - - assert client.service_client._credential is 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) @@ -143,9 +126,6 @@ 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() From 741dd251802eadb3d3bbb69b8f2311cabd791a1d Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Tue, 17 Mar 2026 09:16:30 +0100 Subject: [PATCH 6/7] Ignore pixi config and lock file --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 8363e9b8..c282d5af 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,8 @@ ENV/ # IDE settings .vscode/ +# pixi environments +.pixi/* +!.pixi/config.toml +pixi.toml +pixi.lock From c320d959509501f078ee7252ffeb687a55bb3efc Mon Sep 17 00:00:00 2001 From: Jan Jagusch Date: Tue, 17 Mar 2026 09:17:05 +0100 Subject: [PATCH 7/7] Add check_access test for azure rigs with default azure credentials --- tests/test_azure_specific.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_azure_specific.py b/tests/test_azure_specific.py index 0dd30105..c635e563 100644 --- a/tests/test_azure_specific.py +++ b/tests/test_azure_specific.py @@ -228,6 +228,10 @@ def _check_access(az_client, gen2=False): cl: AzureBlobClient = azure_rigs.client_class(credential=credential, account_url=bsc.url) _check_access(cl, gen2=azure_rigs.is_adls_gen2) + # test DefaultAzureCredential used automatically with only account_url + cl = azure_rigs.client_class(account_url=bsc.url) + _check_access(cl, gen2=azure_rigs.is_adls_gen2) + # add basic checks for gen2 to exercise limited-privilege access scenarios p = azure_rigs.create_cloud_path("new_dir/new_file.txt", client=cl) assert cl._check_hns(p) == azure_rigs.is_adls_gen2