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 17 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
37 changes: 37 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,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`. It requires the packages `azure-keyvault-secrets` and `azure-identity`, and the Azure role `Key Vault Administrator`.

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

### Azure Key Vault

```
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['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, os.environ['KEY_VAULT__URL'], DefaultAzureCredential()
),
)
```

## Field value priority

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


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

try:
from azure.core.credentials import TokenCredential
from azure.core.exceptions import ResourceNotFoundError
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 @@ -877,6 +892,46 @@ 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,
credential: TokenCredential, # type: ignore
) -> None:
import_azure_key_vault()
super().__init__(settings_cls)
self._secret_client = SecretClient(vault_url=url, credential=credential) # type: ignore

def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
field_value: Any | None = None

# Azure Key Vault uses "-" instead of "_"
secret_name = field_name.replace('_', '-')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need to use _extract_field_info to respect the field alias if defined.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is complex and I don't see the point in aliasing secret names. Can we iterate in the next version?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the basic functionality that we already have in all of the source classes.

You need to move the _extract_field_info to PydanticBaseSettingsSource class and then call it here. it returns a list of tuples that contain field_key, env_name and value_is_complex. you can use the env_name to fetch the value from key vault . there are some use-cases of _extract_field_info in the code, you can check them as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the alias support.


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

return field_value, field_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 _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
10 changes: 5 additions & 5 deletions requirements/linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
# 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
Expand All @@ -16,7 +16,7 @@ filelock==3.13.4
# 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
Expand All @@ -28,7 +28,7 @@ packaging==24.0
# via black
pathspec==0.12.1
# via black
platformdirs==4.2.0
platformdirs==4.2.1
# 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.2
# via -r requirements/linting.in
tokenize-rt==5.2.0
# via pyupgrade
Expand All @@ -54,7 +54,7 @@ typing-extensions==4.11.0
# via
# black
# mypy
virtualenv==20.25.3
virtualenv==20.26.0
# via pre-commit

# The following packages are considered to be unsafe in a requirements file:
Expand Down
57 changes: 54 additions & 3 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
# via pydantic
pydantic==2.7.0
azure-core==1.30.1
# 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.2.2
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
cryptography==42.0.5
# 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.1
# via pydantic-settings (pyproject.toml)
pydantic-core==2.18.1
pydantic-core==2.18.2
# 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.31.0
# 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
# via
# annotated-types
# azure-core
# azure-keyvault-secrets
# pydantic
# pydantic-core
urllib3==2.2.1
# via requests
10 changes: 5 additions & 5 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.0
# 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.1
# via black
pluggy==1.5.0
# via pytest
pygments==2.17.2
# via rich
pytest==8.1.1
pytest==8.2.0
# via
# -r requirements/testing.in
# pytest-examples
Expand All @@ -46,7 +46,7 @@ pytest-pretty==1.2.0
# via -r requirements/testing.in
rich==13.7.1
# via pytest-pretty
ruff==0.4.1
ruff==0.4.2
# via pytest-examples
tomli==2.0.1
# via
Expand Down
63 changes: 62 additions & 1 deletion tests/test_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,29 @@
from typing import TYPE_CHECKING

import pytest
from pydantic.fields import FieldInfo

from pydantic_settings.main import BaseSettings, SettingsConfigDict
from pydantic_settings.sources import PyprojectTomlConfigSettingsSource
from pydantic_settings.sources import (
AzureKeyVaultSettingsSource,
PyprojectTomlConfigSettingsSource,
import_azure_key_vault,
)

try:
import tomli
except ImportError:
tomli = None


try:
azure_key_vault = True
import_azure_key_vault()
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import KeyVaultSecret, SecretProperties
except ImportError:
azure_key_vault = None

if TYPE_CHECKING:
from pathlib import Path

Expand Down Expand Up @@ -97,3 +111,50 @@ def test___init___parent(self, mocker: MockerFixture, tmp_path: Path) -> None:
assert obj.toml_table_header == ('some', 'table')
assert obj.toml_data == {'field': 'some'}
assert obj.toml_file_path == tmp_path / 'pyproject.toml'


@pytest.mark.skipif(azure_key_vault is None, reason='azure-keyvault-secrets and azure-identity are not installed')
class TestAzureKeyVaultSettingsSource:
"""Test AzureKeyVaultSettingsSource."""

def test___init__(self) -> None:
"""Test __init__."""

class AzureKeyVaultSettings(BaseSettings):
"""AzureKeyVault settings."""

AzureKeyVaultSettingsSource(
AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential()
)

def test_get_field_value(self, mocker: MockerFixture) -> None:
"""Test _get_field_value."""

class AzureKeyVaultSettings(BaseSettings):
"""AzureKeyVault settings."""

key_vault_secret = KeyVaultSecret(SecretProperties(), 'SecretValue')
mocker.patch(f'{MODULE}.SecretClient.get_secret', return_value=key_vault_secret)
obj = AzureKeyVaultSettingsSource(
AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential()
)

obj.get_field_value(field=FieldInfo(), field_name='sqlserverpassword')
AndreuCodina marked this conversation as resolved.
Show resolved Hide resolved

def test___call__(self, mocker: MockerFixture) -> None:
"""Test __cal__."""

class AzureKeyVaultSettings(BaseSettings):
"""AzureKeyVault settings."""

sqlserverpassword: str
SQLSERVERPASSWORD: str
sql_server__password: str

key_vault_secret = KeyVaultSecret(SecretProperties(), 'SecretValue')
mocker.patch(f'{MODULE}.SecretClient.get_secret', return_value=key_vault_secret)
obj = AzureKeyVaultSettingsSource(
AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential()
)

obj()
AndreuCodina marked this conversation as resolved.
Show resolved Hide resolved