Skip to content

Commit 4c8520a

Browse files
d15kyololokshramezani
authored
Azure Key Vault case insensitive support and dash-underscore translation (#607)
Co-authored-by: Oleksa <[email protected]> Co-authored-by: Hasan Ramezani <[email protected]>
1 parent 212784c commit 4c8520a

File tree

3 files changed

+119
-12
lines changed

3 files changed

+119
-12
lines changed

docs/index.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,6 +1889,52 @@ class AzureKeyVaultSettings(BaseSettings):
18891889
)
18901890
```
18911891

1892+
### Dash to underscore mapping
1893+
1894+
The Azure Key Vault source accepts a `dash_to_underscore` option, disabled by default, to support Key Vault kebab-case secret names by mapping them to Python's snake_case field names. When enabled, dashes (`-`) in secret names are mapped to underscores (`_`) in field names during validation.
1895+
1896+
This mapping applies only to *field names*, not to aliases.
1897+
1898+
```py
1899+
import os
1900+
1901+
from azure.identity import DefaultAzureCredential
1902+
from pydantic import Field
1903+
1904+
from pydantic_settings import (
1905+
AzureKeyVaultSettingsSource,
1906+
BaseSettings,
1907+
PydanticBaseSettingsSource,
1908+
)
1909+
1910+
1911+
class AzureKeyVaultSettings(BaseSettings):
1912+
field_with_underscore: str
1913+
field_with_alias: str = Field(..., alias='Alias-With-Dashes')
1914+
1915+
@classmethod
1916+
def settings_customise_sources(
1917+
cls,
1918+
settings_cls: type[BaseSettings],
1919+
init_settings: PydanticBaseSettingsSource,
1920+
env_settings: PydanticBaseSettingsSource,
1921+
dotenv_settings: PydanticBaseSettingsSource,
1922+
file_secret_settings: PydanticBaseSettingsSource,
1923+
) -> tuple[PydanticBaseSettingsSource, ...]:
1924+
az_key_vault_settings = AzureKeyVaultSettingsSource(
1925+
settings_cls,
1926+
os.environ['AZURE_KEY_VAULT_URL'],
1927+
DefaultAzureCredential(),
1928+
dash_to_underscore=True,
1929+
)
1930+
return (az_key_vault_settings,)
1931+
```
1932+
1933+
This setup will load Azure Key Vault secrets named `field-with-underscore` and `Alias-With-Dashes`, mapping them to the `field_with_underscore` and `field_with_alias` fields, respectively.
1934+
1935+
!!! tip
1936+
Alternatively, you can configure an [alias_generator](alias.md#using-alias-generators) to map PascalCase secrets.
1937+
18921938
## Google Cloud Secret Manager
18931939

18941940
Google Cloud Secret Manager allows you to store, manage, and access sensitive information as secrets in Google Cloud Platform. This integration lets you retrieve secrets directly from GCP Secret Manager for use in your Pydantic settings.

pydantic_settings/sources/providers/azure.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from collections.abc import Iterator, Mapping
66
from typing import TYPE_CHECKING, Optional
77

8+
from pydantic.fields import FieldInfo
9+
810
from .env import EnvSettingsSource
911

1012
if TYPE_CHECKING:
@@ -42,27 +44,33 @@ class AzureKeyVaultMapping(Mapping[str, Optional[str]]):
4244
def __init__(
4345
self,
4446
secret_client: SecretClient,
47+
case_sensitive: bool,
4548
) -> None:
4649
self._loaded_secrets = {}
4750
self._secret_client = secret_client
48-
self._secret_names: list[str] = [
51+
self._case_sensitive = case_sensitive
52+
self._secret_map: dict[str, str] = self._load_remote()
53+
54+
def _load_remote(self) -> dict[str, str]:
55+
secret_names: Iterator[str] = (
4956
secret.name for secret in self._secret_client.list_properties_of_secrets() if secret.name and secret.enabled
50-
]
57+
)
58+
if self._case_sensitive:
59+
return {name: name for name in secret_names}
60+
return {name.lower(): name for name in secret_names}
5161

5262
def __getitem__(self, key: str) -> str | None:
53-
if key not in self._loaded_secrets and key in self._secret_names:
54-
try:
55-
self._loaded_secrets[key] = self._secret_client.get_secret(key).value
56-
except Exception:
57-
raise KeyError(key)
58-
63+
if not self._case_sensitive:
64+
key = key.lower()
65+
if key not in self._loaded_secrets and key in self._secret_map:
66+
self._loaded_secrets[key] = self._secret_client.get_secret(self._secret_map[key]).value
5967
return self._loaded_secrets[key]
6068

6169
def __len__(self) -> int:
62-
return len(self._secret_names)
70+
return len(self._secret_map)
6371

6472
def __iter__(self) -> Iterator[str]:
65-
return iter(self._secret_names)
73+
return iter(self._secret_map.keys())
6674

6775

6876
class AzureKeyVaultSettingsSource(EnvSettingsSource):
@@ -74,16 +82,19 @@ def __init__(
7482
settings_cls: type[BaseSettings],
7583
url: str,
7684
credential: TokenCredential,
85+
dash_to_underscore: bool = False,
86+
case_sensitive: bool | None = None,
7787
env_prefix: str | None = None,
7888
env_parse_none_str: str | None = None,
7989
env_parse_enums: bool | None = None,
8090
) -> None:
8191
import_azure_key_vault()
8292
self._url = url
8393
self._credential = credential
94+
self._dash_to_underscore = dash_to_underscore
8495
super().__init__(
8596
settings_cls,
86-
case_sensitive=True,
97+
case_sensitive=case_sensitive,
8798
env_prefix=env_prefix,
8899
env_nested_delimiter='--',
89100
env_ignore_empty=False,
@@ -93,7 +104,12 @@ def __init__(
93104

94105
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
95106
secret_client = SecretClient(vault_url=self._url, credential=self._credential)
96-
return AzureKeyVaultMapping(secret_client)
107+
return AzureKeyVaultMapping(secret_client, self.case_sensitive)
108+
109+
def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]:
110+
if self._dash_to_underscore:
111+
return list((x[0], x[1].replace('_', '-'), x[2]) for x in super()._extract_field_info(field, field_name))
112+
return super()._extract_field_info(field, field_name)
97113

98114
def __repr__(self) -> str:
99115
return f'{self.__class__.__name__}(url={self._url!r}, env_nested_delimiter={self.env_nested_delimiter!r})'

tests/test_source_azure_key_vault.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,48 @@ def _raise_resource_not_found_when_getting_parent_secret_name(self, secret_name:
160160
raise ResourceNotFoundError()
161161

162162
return key_vault_secret
163+
164+
def test_dash_to_underscore_translation(self, mocker: MockerFixture) -> None:
165+
"""Test that dashes in secret names are mapped to underscores in field names."""
166+
167+
class AzureKeyVaultSettings(BaseSettings):
168+
my_field: str
169+
alias_field: str = Field(..., alias='Secret-Alias')
170+
171+
@classmethod
172+
def settings_customise_sources(
173+
cls,
174+
settings_cls: type[BaseSettings],
175+
init_settings: PydanticBaseSettingsSource,
176+
env_settings: PydanticBaseSettingsSource,
177+
dotenv_settings: PydanticBaseSettingsSource,
178+
file_secret_settings: PydanticBaseSettingsSource,
179+
) -> tuple[PydanticBaseSettingsSource, ...]:
180+
return (
181+
AzureKeyVaultSettingsSource(
182+
settings_cls,
183+
'https://my-resource.vault.azure.net/',
184+
DefaultAzureCredential(),
185+
dash_to_underscore=True,
186+
),
187+
)
188+
189+
expected_secrets = [
190+
type('', (), {'name': 'my-field', 'enabled': True}),
191+
type('', (), {'name': 'Secret-Alias', 'enabled': True}),
192+
]
193+
expected_secret_value = 'SecretValue'
194+
195+
mocker.patch(
196+
f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}',
197+
return_value=expected_secrets,
198+
)
199+
mocker.patch(
200+
f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.get_secret.__qualname__}',
201+
return_value=KeyVaultSecret(SecretProperties(), expected_secret_value),
202+
)
203+
204+
settings = AzureKeyVaultSettings()
205+
206+
assert settings.my_field == expected_secret_value
207+
assert settings.alias_field == expected_secret_value

0 commit comments

Comments
 (0)