Skip to content

Commit e23d7e5

Browse files
janjaguschclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 5124aa0 commit e23d7e5

File tree

3 files changed

+172
-22
lines changed

3 files changed

+172
-22
lines changed

cloudpathlib/azure/azblobclient.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
except ModuleNotFoundError:
4343
implementation_registry["azure"].dependencies_loaded = False
4444

45+
try:
46+
from azure.identity import DefaultAzureCredential
47+
except ImportError:
48+
DefaultAzureCredential = None
49+
4550

4651
@register_client_class("azure")
4752
class AzureBlobClient(Client):
@@ -66,20 +71,23 @@ def __init__(
6671
https://docs.microsoft.com/en-us/python/api/azure-storage-blob/azure.storage.blob.blobserviceclient?view=azure-python).
6772
Supports the following authentication methods of `BlobServiceClient`.
6873
69-
- Environment variable `""AZURE_STORAGE_CONNECTION_STRING"` containing connecting string
74+
- Environment variable `AZURE_STORAGE_CONNECTION_STRING` containing connecting string
7075
with account credentials. See [Azure Storage SDK documentation](
7176
https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-python#copy-your-credentials-from-the-azure-portal).
77+
- Environment variable `AZURE_STORAGE_ACCOUNT_URL` containing the account URL. If
78+
`azure-identity` is installed, `DefaultAzureCredential` will be used automatically.
7279
- Connection string via `connection_string`, authenticated either with an embedded SAS
7380
token or with credentials passed to `credentials`.
7481
- Account URL via `account_url`, authenticated either with an embedded SAS token, or with
75-
credentials passed to `credentials`.
82+
credentials passed to `credentials`. If `credential` is not provided and `azure-identity`
83+
is installed, `DefaultAzureCredential` will be used automatically.
7684
- Instantiated and already authenticated [`BlobServiceClient`](
7785
https://docs.microsoft.com/en-us/python/api/azure-storage-blob/azure.storage.blob.blobserviceclient?view=azure-python) or
7886
[`DataLakeServiceClient`](https://learn.microsoft.com/en-us/python/api/azure-storage-file-datalake/azure.storage.filedatalake.datalakeserviceclient).
7987
8088
If multiple methods are used, priority order is reverse of list above (later in list takes
8189
priority). If no methods are used, a [`MissingCredentialsError`][cloudpathlib.exceptions.MissingCredentialsError]
82-
exception will be raised raised.
90+
exception will be raised.
8391
8492
Args:
8593
account_url (Optional[str]): The URL to the blob storage account, optionally
@@ -117,6 +125,8 @@ def __init__(
117125

118126
if connection_string is None:
119127
connection_string = os.getenv("AZURE_STORAGE_CONNECTION_STRING", None)
128+
if account_url is None:
129+
account_url = os.getenv("AZURE_STORAGE_ACCOUNT_URL", None)
120130

121131
self.data_lake_client: Optional[DataLakeServiceClient] = (
122132
None # only needs to end up being set if HNS is enabled
@@ -174,25 +184,9 @@ def __init__(
174184
conn_str=connection_string, credential=credential
175185
)
176186
elif account_url is not None:
177-
if ".dfs." in account_url:
178-
self.service_client = BlobServiceClient(
179-
account_url=account_url.replace(".dfs.", ".blob."), credential=credential
180-
)
181-
self.data_lake_client = DataLakeServiceClient(
182-
account_url=account_url, credential=credential
183-
)
184-
elif ".blob." in account_url:
185-
self.service_client = BlobServiceClient(
186-
account_url=account_url, credential=credential
187-
)
188-
self.data_lake_client = DataLakeServiceClient(
189-
account_url=account_url.replace(".blob.", ".dfs."), credential=credential
190-
)
191-
else:
192-
# assume default to blob; HNS not supported
193-
self.service_client = BlobServiceClient(
194-
account_url=account_url, credential=credential
195-
)
187+
if credential is None and DefaultAzureCredential is not None:
188+
credential = DefaultAzureCredential()
189+
self._init_clients_from_account_url(account_url, credential)
196190

197191
else:
198192
raise MissingCredentialsError(
@@ -202,6 +196,27 @@ def __init__(
202196

203197
self._hns_enabled: Optional[bool] = None
204198

199+
def _init_clients_from_account_url(self, account_url: str, credential: Optional[Any]) -> None:
200+
if ".dfs." in account_url:
201+
self.service_client = BlobServiceClient(
202+
account_url=account_url.replace(".dfs.", ".blob."), credential=credential
203+
)
204+
self.data_lake_client = DataLakeServiceClient(
205+
account_url=account_url, credential=credential
206+
)
207+
elif ".blob." in account_url:
208+
self.service_client = BlobServiceClient(
209+
account_url=account_url, credential=credential
210+
)
211+
self.data_lake_client = DataLakeServiceClient(
212+
account_url=account_url.replace(".blob.", ".dfs."), credential=credential
213+
)
214+
else:
215+
# assume default to blob; HNS not supported
216+
self.service_client = BlobServiceClient(
217+
account_url=account_url, credential=credential
218+
)
219+
205220
def _check_hns(self, cloud_path: AzureBlobPath) -> Optional[bool]:
206221
if self._hns_enabled is None:
207222
try:

cloudpathlib/local/implementations/azure.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def __init__(self, *args, **kwargs):
2424
kwargs.get("connection_string", None),
2525
kwargs.get("account_url", None),
2626
os.getenv("AZURE_STORAGE_CONNECTION_STRING", None),
27+
os.getenv("AZURE_STORAGE_ACCOUNT_URL", None),
2728
]
2829
super().__init__(*args, **kwargs)
2930

tests/test_azure_specific.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from unittest.mock import MagicMock, patch
23

34
from azure.core.credentials import AzureNamedKeyCredential
45
from azure.identity import DefaultAzureCredential
@@ -39,10 +40,143 @@ def test_azureblobpath_properties(path_class, monkeypatch):
3940
@pytest.mark.parametrize("client_class", [AzureBlobClient, LocalAzureBlobClient])
4041
def test_azureblobpath_nocreds(client_class, monkeypatch):
4142
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
43+
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
44+
monkeypatch.setattr(
45+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", None
46+
)
4247
with pytest.raises(MissingCredentialsError):
4348
client_class()
4449

4550

51+
def test_default_credential_used_with_account_url(monkeypatch):
52+
"""DefaultAzureCredential is used when account_url is provided without credential."""
53+
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
54+
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
55+
56+
mock_dac = MagicMock()
57+
mock_dac_class = MagicMock(return_value=mock_dac)
58+
monkeypatch.setattr(
59+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
60+
)
61+
62+
with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
63+
DataLakeServiceClient, "__init__", return_value=None
64+
) as mock_datalake:
65+
AzureBlobClient(account_url="https://myaccount.blob.core.windows.net")
66+
67+
mock_dac_class.assert_called_once()
68+
mock_blob.assert_called_once_with(
69+
account_url="https://myaccount.blob.core.windows.net", credential=mock_dac
70+
)
71+
mock_datalake.assert_called_once_with(
72+
account_url="https://myaccount.dfs.core.windows.net", credential=mock_dac
73+
)
74+
75+
76+
def test_no_default_credential_when_explicit_credential(monkeypatch):
77+
"""DefaultAzureCredential is NOT used when an explicit credential is provided."""
78+
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
79+
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
80+
81+
mock_dac_class = MagicMock()
82+
monkeypatch.setattr(
83+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
84+
)
85+
86+
explicit_cred = MagicMock()
87+
with patch.object(BlobServiceClient, "__init__", return_value=None), patch.object(
88+
DataLakeServiceClient, "__init__", return_value=None
89+
):
90+
AzureBlobClient(
91+
account_url="https://myaccount.blob.core.windows.net",
92+
credential=explicit_cred,
93+
)
94+
95+
mock_dac_class.assert_not_called()
96+
97+
98+
def test_fallback_when_azure_identity_not_installed(monkeypatch):
99+
"""When azure-identity is not installed, credential=None is passed through."""
100+
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
101+
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
102+
monkeypatch.setattr(
103+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", None
104+
)
105+
106+
with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
107+
DataLakeServiceClient, "__init__", return_value=None
108+
):
109+
AzureBlobClient(account_url="https://myaccount.blob.core.windows.net")
110+
111+
mock_blob.assert_called_once_with(
112+
account_url="https://myaccount.blob.core.windows.net", credential=None
113+
)
114+
115+
116+
def test_account_url_env_var_blob(monkeypatch):
117+
"""AZURE_STORAGE_ACCOUNT_URL env var with .blob. URL creates both clients."""
118+
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
119+
monkeypatch.setenv(
120+
"AZURE_STORAGE_ACCOUNT_URL", "https://myaccount.blob.core.windows.net"
121+
)
122+
123+
mock_dac = MagicMock()
124+
mock_dac_class = MagicMock(return_value=mock_dac)
125+
monkeypatch.setattr(
126+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
127+
)
128+
129+
with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
130+
DataLakeServiceClient, "__init__", return_value=None
131+
) as mock_datalake:
132+
AzureBlobClient()
133+
134+
mock_dac_class.assert_called_once()
135+
mock_blob.assert_called_once_with(
136+
account_url="https://myaccount.blob.core.windows.net", credential=mock_dac
137+
)
138+
mock_datalake.assert_called_once_with(
139+
account_url="https://myaccount.dfs.core.windows.net", credential=mock_dac
140+
)
141+
142+
143+
def test_account_url_env_var_dfs(monkeypatch):
144+
"""AZURE_STORAGE_ACCOUNT_URL env var with .dfs. URL creates both clients."""
145+
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
146+
monkeypatch.setenv(
147+
"AZURE_STORAGE_ACCOUNT_URL", "https://myaccount.dfs.core.windows.net"
148+
)
149+
150+
mock_dac = MagicMock()
151+
mock_dac_class = MagicMock(return_value=mock_dac)
152+
monkeypatch.setattr(
153+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", mock_dac_class
154+
)
155+
156+
with patch.object(BlobServiceClient, "__init__", return_value=None) as mock_blob, patch.object(
157+
DataLakeServiceClient, "__init__", return_value=None
158+
) as mock_datalake:
159+
AzureBlobClient()
160+
161+
mock_blob.assert_called_once_with(
162+
account_url="https://myaccount.blob.core.windows.net", credential=mock_dac
163+
)
164+
mock_datalake.assert_called_once_with(
165+
account_url="https://myaccount.dfs.core.windows.net", credential=mock_dac
166+
)
167+
168+
169+
def test_missing_creds_error_no_env_vars(monkeypatch):
170+
"""MissingCredentialsError is still raised when nothing is configured."""
171+
monkeypatch.delenv("AZURE_STORAGE_CONNECTION_STRING", raising=False)
172+
monkeypatch.delenv("AZURE_STORAGE_ACCOUNT_URL", raising=False)
173+
monkeypatch.setattr(
174+
"cloudpathlib.azure.azblobclient.DefaultAzureCredential", None
175+
)
176+
with pytest.raises(MissingCredentialsError):
177+
AzureBlobClient()
178+
179+
46180
def test_as_url(azure_rigs):
47181
p: AzureBlobPath = azure_rigs.create_cloud_path("dir_0/file0_0.txt")
48182

0 commit comments

Comments
 (0)