Skip to content

Commit c22cef4

Browse files
authored
Snake case conversion in Azure Key Vault (#680)
1 parent 9c6c9b5 commit c22cef4

File tree

3 files changed

+134
-8
lines changed

3 files changed

+134
-8
lines changed

docs/index.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1970,6 +1970,45 @@ class AzureKeyVaultSettings(BaseSettings):
19701970
)
19711971
```
19721972

1973+
### Snake case conversion
1974+
1975+
The Azure Key Vault source accepts a `snake_case_convertion` option, disabled by default, to convert Key Vault secret names by mapping them to Python's snake_case field names, without the need to use aliases.
1976+
1977+
```py
1978+
import os
1979+
1980+
from azure.identity import DefaultAzureCredential
1981+
1982+
from pydantic_settings import (
1983+
AzureKeyVaultSettingsSource,
1984+
BaseSettings,
1985+
PydanticBaseSettingsSource,
1986+
)
1987+
1988+
1989+
class AzureKeyVaultSettings(BaseSettings):
1990+
my_setting: str
1991+
1992+
@classmethod
1993+
def settings_customise_sources(
1994+
cls,
1995+
settings_cls: type[BaseSettings],
1996+
init_settings: PydanticBaseSettingsSource,
1997+
env_settings: PydanticBaseSettingsSource,
1998+
dotenv_settings: PydanticBaseSettingsSource,
1999+
file_secret_settings: PydanticBaseSettingsSource,
2000+
) -> tuple[PydanticBaseSettingsSource, ...]:
2001+
az_key_vault_settings = AzureKeyVaultSettingsSource(
2002+
settings_cls,
2003+
os.environ['AZURE_KEY_VAULT_URL'],
2004+
DefaultAzureCredential(),
2005+
snake_case_conversion=True,
2006+
)
2007+
return (az_key_vault_settings,)
2008+
```
2009+
2010+
This setup will load Azure Key Vault secrets (e.g., `MySetting`, `mySetting`, `my-secret` or `MY-SECRET`), mapping them to the snake case version (`my_setting` in this case).
2011+
19732012
### Dash to underscore mapping
19742013

19752014
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.

pydantic_settings/sources/providers/azure.py

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

8+
from pydantic.alias_generators import to_snake
89
from pydantic.fields import FieldInfo
910

1011
from .env import EnvSettingsSource
@@ -45,26 +46,42 @@ def __init__(
4546
self,
4647
secret_client: SecretClient,
4748
case_sensitive: bool,
49+
snake_case_conversion: bool,
4850
) -> None:
4951
self._loaded_secrets = {}
5052
self._secret_client = secret_client
5153
self._case_sensitive = case_sensitive
54+
self._snake_case_conversion = snake_case_conversion
5255
self._secret_map: dict[str, str] = self._load_remote()
5356

5457
def _load_remote(self) -> dict[str, str]:
5558
secret_names: Iterator[str] = (
5659
secret.name for secret in self._secret_client.list_properties_of_secrets() if secret.name and secret.enabled
5760
)
61+
62+
if self._snake_case_conversion:
63+
return {to_snake(name): name for name in secret_names}
64+
5865
if self._case_sensitive:
5966
return {name: name for name in secret_names}
67+
6068
return {name.lower(): name for name in secret_names}
6169

6270
def __getitem__(self, key: str) -> str | None:
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
67-
return self._loaded_secrets[key]
71+
new_key = key
72+
73+
if self._snake_case_conversion:
74+
new_key = to_snake(key)
75+
elif not self._case_sensitive:
76+
new_key = key.lower()
77+
78+
if new_key not in self._loaded_secrets:
79+
if new_key in self._secret_map:
80+
self._loaded_secrets[new_key] = self._secret_client.get_secret(self._secret_map[new_key]).value
81+
else:
82+
raise KeyError(key)
83+
84+
return self._loaded_secrets[new_key]
6885

6986
def __len__(self) -> int:
7087
return len(self._secret_map)
@@ -84,6 +101,7 @@ def __init__(
84101
credential: TokenCredential,
85102
dash_to_underscore: bool = False,
86103
case_sensitive: bool | None = None,
104+
snake_case_conversion: bool = False,
87105
env_prefix: str | None = None,
88106
env_parse_none_str: str | None = None,
89107
env_parse_enums: bool | None = None,
@@ -92,23 +110,32 @@ def __init__(
92110
self._url = url
93111
self._credential = credential
94112
self._dash_to_underscore = dash_to_underscore
113+
self._snake_case_conversion = snake_case_conversion
95114
super().__init__(
96115
settings_cls,
97-
case_sensitive=case_sensitive,
116+
case_sensitive=False if snake_case_conversion else case_sensitive,
98117
env_prefix=env_prefix,
99-
env_nested_delimiter='--',
118+
env_nested_delimiter='__' if snake_case_conversion else '--',
100119
env_ignore_empty=False,
101120
env_parse_none_str=env_parse_none_str,
102121
env_parse_enums=env_parse_enums,
103122
)
104123

105124
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
106125
secret_client = SecretClient(vault_url=self._url, credential=self._credential)
107-
return AzureKeyVaultMapping(secret_client, self.case_sensitive)
126+
return AzureKeyVaultMapping(
127+
secret_client=secret_client,
128+
case_sensitive=self.case_sensitive,
129+
snake_case_conversion=self._snake_case_conversion,
130+
)
108131

109132
def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]:
133+
if self._snake_case_conversion:
134+
return list((x[0], x[0], x[2]) for x in super()._extract_field_info(field, field_name))
135+
110136
if self._dash_to_underscore:
111137
return list((x[0], x[1].replace('_', '-'), x[2]) for x in super()._extract_field_info(field, field_name))
138+
112139
return super()._extract_field_info(field, field_name)
113140

114141
def __repr__(self) -> str:

tests/test_source_azure_key_vault.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,63 @@ def settings_customise_sources(
205205

206206
assert settings.my_field == expected_secret_value
207207
assert settings.alias_field == expected_secret_value
208+
209+
def test_snake_case_conversion(self, mocker: MockerFixture) -> None:
210+
"""Test that secret names are mapped to snake case in field names."""
211+
212+
class NestedModel(BaseModel):
213+
nested_field: str
214+
215+
class AzureKeyVaultSettings(BaseSettings):
216+
my_field_from_kebab_case: str
217+
my_field_from_pascal_case: str
218+
my_field_from_camel_case: str
219+
alias_field: str = Field(alias='Secret-Alias')
220+
alias_field_2: str = Field(alias='another-SECRET-AliaS')
221+
nested_model: NestedModel
222+
223+
@classmethod
224+
def settings_customise_sources(
225+
cls,
226+
settings_cls: type[BaseSettings],
227+
init_settings: PydanticBaseSettingsSource,
228+
env_settings: PydanticBaseSettingsSource,
229+
dotenv_settings: PydanticBaseSettingsSource,
230+
file_secret_settings: PydanticBaseSettingsSource,
231+
) -> tuple[PydanticBaseSettingsSource, ...]:
232+
return (
233+
AzureKeyVaultSettingsSource(
234+
settings_cls,
235+
'https://my-resource.vault.azure.net/',
236+
DefaultAzureCredential(),
237+
snake_case_conversion=True,
238+
),
239+
)
240+
241+
expected_secrets = [
242+
type('', (), {'name': 'my-field-from-kebab-case', 'enabled': True}),
243+
type('', (), {'name': 'MyFieldFromPascalCase', 'enabled': True}),
244+
type('', (), {'name': 'myFieldFromCamelCase', 'enabled': True}),
245+
type('', (), {'name': 'Secret-Alias', 'enabled': True}),
246+
type('', (), {'name': 'another-SECRET-AliaS', 'enabled': True}),
247+
type('', (), {'name': 'NestedModel--NestedField', 'enabled': True}),
248+
]
249+
expected_secret_value = 'SecretValue'
250+
251+
mocker.patch(
252+
f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}',
253+
return_value=expected_secrets,
254+
)
255+
mocker.patch(
256+
f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.get_secret.__qualname__}',
257+
return_value=KeyVaultSecret(SecretProperties(), expected_secret_value),
258+
)
259+
260+
settings = AzureKeyVaultSettings()
261+
262+
assert settings.my_field_from_kebab_case == expected_secret_value
263+
assert settings.my_field_from_pascal_case == expected_secret_value
264+
assert settings.my_field_from_camel_case == expected_secret_value
265+
assert settings.alias_field == expected_secret_value
266+
assert settings.alias_field_2 == expected_secret_value
267+
assert settings.nested_model.nested_field == expected_secret_value

0 commit comments

Comments
 (0)