Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Azure Key Vault settings source #272

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8cab66d
Add Azure Key Vault settings source
AndreuCodina Apr 21, 2024
ab2c17f
Add Azure Key Vault settings source
AndreuCodina Apr 21, 2024
192da43
Add Azure Key Vault settings source
AndreuCodina Apr 21, 2024
8202ebe
Add optional dependencies
AndreuCodina Apr 21, 2024
357df2b
Fix lint errors
AndreuCodina Apr 21, 2024
30681de
Fix lint error
AndreuCodina Apr 21, 2024
468a55b
Fix mypy errors
AndreuCodina Apr 21, 2024
221731f
Imports inside a function
AndreuCodina Apr 21, 2024
92b6c34
Fix lint errors
AndreuCodina Apr 21, 2024
3e74b93
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Apr 27, 2024
574debf
make refresh-lockfiles
AndreuCodina Apr 27, 2024
9452ba3
make refresh-lockfiles
AndreuCodina Apr 27, 2024
df7e900
Add unit tests
AndreuCodina Apr 28, 2024
be0e201
Fix lint errors
AndreuCodina Apr 28, 2024
9d08fa1
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Apr 28, 2024
46de93c
make refresh-lockfiles with Python 3.8
AndreuCodina Apr 28, 2024
5edb8f5
Resolve PR comments
AndreuCodina Apr 28, 2024
a4157e3
Merge branch 'pydantic:main' into feature/azure-key-vault-source-sett…
AndreuCodina May 14, 2024
4b3df3f
Add tests
AndreuCodina May 14, 2024
643264f
Fix lint errors
AndreuCodina May 14, 2024
794487c
Fix lint errors
AndreuCodina May 14, 2024
176233d
Merge branch 'pydantic:main' into feature/azure-key-vault-source-sett…
AndreuCodina Jun 7, 2024
24a1fed
Add alias support
AndreuCodina Jun 7, 2024
468bf15
Fix CI errors
AndreuCodina Jun 7, 2024
97ade35
Fix CI errors
AndreuCodina Jun 7, 2024
a6cb2b8
make refresh-lockfiles
AndreuCodina Jun 7, 2024
9dbb8f0
Typo
AndreuCodina Jun 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ refresh-lockfiles:
find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete
pip-compile -q --no-emit-index-url --resolver backtracking -o requirements/linting.txt requirements/linting.in
pip-compile -q --no-emit-index-url --resolver backtracking -o requirements/testing.txt requirements/testing.in
pip-compile -q --no-emit-index-url --resolver backtracking --extra toml --extra yaml -o requirements/pyproject.txt pyproject.toml
pip-compile -q --no-emit-index-url --resolver backtracking --extra toml --extra yaml --extra azure-key-vault -o requirements/pyproject.txt pyproject.toml
pip install --dry-run -r requirements/all.txt

.PHONY: format
Expand Down
38 changes: 38 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,7 @@ Other settings sources are available for common configuration files:
- `PyprojectTomlConfigSettingsSource` using *(optional)* `pyproject_toml_depth` and *(optional)* `pyproject_toml_table_header` arguments
- `TomlConfigSettingsSource` using `toml_file` argument
- `YamlConfigSettingsSource` using `yaml_file` and yaml_file_encoding arguments
- `AzureKeyVaultSettingsSource`.

You can also provide multiple files by providing a list of path:
```py
Expand Down Expand Up @@ -1300,6 +1301,43 @@ class ExplicitFilePathSettings(BaseSettings):
)
```

### Azure Key Vault

You simply set the `AZURE_KEY_VAULT__URL` environment variable and use your role assignement (from the system identity, user managed identity or executing `az login`) to access to the secrets.
Optionally you can provide the URL and the credentials using the constructor.

```
import os
from azure.identity import DefaultAzureCredential
from pydantic import BaseModel
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource
)
from pydantic_settings.sources import AzureKeyVaultSettingsSource


os.environ['AZURE_KEY_VAULT__URL'] = 'https://my-resource.vault.azure.net/'


class AzureKeyVaultSettings(BaseSettings):
my_password: str
sql_server__password: str

@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
AzureKeyVaultSettingsSource(settings_cls),
)
```

## Field value priority

In the case where a value is specified for the same `Settings` field in multiple ways,
Expand Down
82 changes: 82 additions & 0 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,23 @@ def import_toml() -> None:
import tomllib


def import_azure_key_vault() -> None:
global TokenCredential
global ResourceNotFoundError
global SecretClient
global DefaultAzureCredential

try:
from azure.core.credentials import TokenCredential
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
except ImportError as e:
raise ImportError(
'Azure Key Vault dependencies are not installed, run `pip install pydantic-settings[azure-key-vault]`'
) from e


DotenvType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]]
PathType = Union[Path, str, List[Union[Path, str]], Tuple[Union[Path, str], ...]]
DEFAULT_PATH: PathType = Path('')
Expand Down Expand Up @@ -1547,6 +1564,71 @@ def _read_file(self, file_path: Path) -> dict[str, Any]:
return yaml.safe_load(yaml_file)


class AzureKeyVaultSettingsSource(PydanticBaseSettingsSource):
_secret_client: SecretClient # type: ignore

def __init__(
self,
settings_cls: type[BaseSettings],
url: str | None = None,
credential: TokenCredential | None = None, # type: ignore
) -> None:
import_azure_key_vault()
super().__init__(settings_cls)
vault_url = url if url is not None else os.environ['AZURE_KEY_VAULT__URL']
vault_credential = credential if credential is not None else DefaultAzureCredential() # type: ignore
self._secret_client = SecretClient(vault_url=vault_url, credential=vault_credential) # type: ignore

def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
field_value: Any | None = None
field_key, env_name, value_is_complex = self._extract_field_info(field, field_name)[0]
secret_name = env_name.replace('_', '-')

try:
secret = self._secret_client.get_secret(secret_name)
field_value = secret.value
except ResourceNotFoundError: # type: ignore
field_value = None

return field_value, env_name, self.field_is_complex(field)

def __call__(self) -> dict[str, Any]:
data: dict[str, Any] = {}

for field_name, field in self.settings_cls.model_fields.items():
field_value, field_key, value_is_complex = self.get_field_value(field, field_name)
field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex)

if field_value is not None:
data[field_key] = field_value

return data

def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]:
field_info: list[tuple[str, str, bool]] = []
if isinstance(field.validation_alias, (AliasChoices, AliasPath)):
v_alias: str | list[str | int] | list[list[str | int]] | None = field.validation_alias.convert_to_aliases()
else:
v_alias = field.validation_alias

if v_alias:
if isinstance(v_alias, list): # AliasChoices, AliasPath
for alias in v_alias:
if isinstance(alias, str): # AliasPath
field_info.append((alias, alias, True if len(alias) > 1 else False))
elif isinstance(alias, list): # AliasChoices
first_arg = cast(str, alias[0]) # first item of an AliasChoices must be a str
field_info.append((first_arg, first_arg, True if len(alias) > 1 else False))
else: # string validation alias
field_info.append((v_alias, v_alias, False))
elif origin_is_union(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata):
field_info.append((field_name, field_name, True))
else:
field_info.append((field_name, field_name, False))

return field_info


def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
return key if case_sensitive else key.lower()

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dynamic = ['version']
[project.optional-dependencies]
yaml = ["pyyaml>=6.0.1"]
toml = ["tomli>=2.0.1"]
azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"]

[project.urls]
Homepage = 'https://github.com/pydantic/pydantic-settings'
Expand Down
19 changes: 8 additions & 11 deletions requirements/linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,31 @@
#
# pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in
#
black==24.4.0
black==24.4.2
# via -r requirements/linting.in
cfgv==3.4.0
# via pre-commit
click==8.1.7
# via black
distlib==0.3.8
# via virtualenv
filelock==3.13.4
filelock==3.14.0
# via virtualenv
identify==2.5.36
# via pre-commit
mypy==1.9.0
mypy==1.10.0
# via -r requirements/linting.in
mypy-extensions==1.0.0
# via
# black
# mypy
nodeenv==1.8.0
nodeenv==1.9.1
# via pre-commit
packaging==24.0
# via black
pathspec==0.12.1
# via black
platformdirs==4.2.0
platformdirs==4.2.2
# via
# black
# virtualenv
Expand All @@ -40,7 +40,7 @@ pyyaml==6.0.1
# via
# -r requirements/linting.in
# pre-commit
ruff==0.4.1
ruff==0.4.8
# via -r requirements/linting.in
tokenize-rt==5.2.0
# via pyupgrade
Expand All @@ -50,12 +50,9 @@ tomli==2.0.1
# mypy
types-pyyaml==6.0.12.20240311
# via -r requirements/linting.in
typing-extensions==4.11.0
typing-extensions==4.12.1
# via
# black
# mypy
virtualenv==20.25.3
virtualenv==20.26.2
# via pre-commit

# The following packages are considered to be unsafe in a requirements file:
# setuptools
61 changes: 56 additions & 5 deletions requirements/pyproject.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,73 @@
# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# pip-compile --extra=toml --extra=yaml --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml
# pip-compile --extra=azure-key-vault --extra=toml --extra=yaml --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml
#
annotated-types==0.6.0
annotated-types==0.7.0
# via pydantic
pydantic==2.7.0
azure-core==1.30.2
# via
# azure-identity
# azure-keyvault-secrets
azure-identity==1.16.0
# via pydantic-settings (pyproject.toml)
azure-keyvault-secrets==4.8.0
# via pydantic-settings (pyproject.toml)
certifi==2024.6.2
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
cryptography==42.0.8
# via
# azure-identity
# msal
# pyjwt
idna==3.7
# via requests
isodate==0.6.1
# via azure-keyvault-secrets
msal==1.28.0
# via
# azure-identity
# msal-extensions
msal-extensions==1.1.0
# via azure-identity
packaging==24.0
# via msal-extensions
portalocker==2.8.2
# via msal-extensions
pycparser==2.22
# via cffi
pydantic==2.7.3
# via pydantic-settings (pyproject.toml)
pydantic-core==2.18.1
pydantic-core==2.18.4
# via pydantic
pyjwt[crypto]==2.8.0
# via
# msal
# pyjwt
python-dotenv==1.0.1
# via pydantic-settings (pyproject.toml)
pyyaml==6.0.1
# via pydantic-settings (pyproject.toml)
requests==2.32.3
# via
# azure-core
# msal
six==1.16.0
# via
# azure-core
# isodate
tomli==2.0.1
# via pydantic-settings (pyproject.toml)
typing-extensions==4.11.0
typing-extensions==4.12.1
# via
# annotated-types
# azure-core
# azure-keyvault-secrets
# pydantic
# pydantic-core
urllib3==2.2.1
# via requests
14 changes: 7 additions & 7 deletions requirements/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
#
# pip-compile --no-emit-index-url --output-file=requirements/testing.txt requirements/testing.in
#
black==24.4.0
black==24.4.2
# via pytest-examples
click==8.1.7
# via black
coverage[toml]==7.4.4
coverage[toml]==7.5.3
# via -r requirements/testing.in
exceptiongroup==1.2.1
# via pytest
Expand All @@ -26,13 +26,13 @@ packaging==24.0
# pytest
pathspec==0.12.1
# via black
platformdirs==4.2.0
platformdirs==4.2.2
# via black
pluggy==1.5.0
# via pytest
pygments==2.17.2
pygments==2.18.0
# via rich
pytest==8.1.1
pytest==8.2.2
# via
# -r requirements/testing.in
# pytest-examples
Expand All @@ -46,14 +46,14 @@ pytest-pretty==1.2.0
# via -r requirements/testing.in
rich==13.7.1
# via pytest-pretty
ruff==0.4.1
ruff==0.4.8
# via pytest-examples
tomli==2.0.1
# via
# black
# coverage
# pytest
typing-extensions==4.11.0
typing-extensions==4.12.1
# via
# black
# rich