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

Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
53 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
3c2f276
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Jun 12, 2024
f1a3950
Improve documentation
AndreuCodina Jun 12, 2024
4a09d62
Remove environment variable
AndreuCodina Jun 13, 2024
4cbe55e
Fix lint error
AndreuCodina Jun 13, 2024
decfcb9
Inherit from EnvSettingsSource
AndreuCodina Jun 20, 2024
aebe421
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Jun 20, 2024
a8d7366
Fix lint errors
AndreuCodina Jun 20, 2024
991a417
make refresh-lockfile
AndreuCodina Jun 20, 2024
b163475
Remove not needed code
AndreuCodina Jun 21, 2024
f556d6f
Remove prefix support
AndreuCodina Jun 21, 2024
7f6f742
Remove unused code
AndreuCodina Jun 21, 2024
5a05c59
Move documentation
AndreuCodina Jun 24, 2024
4f4175c
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Jun 24, 2024
f3f14b2
Remove line
AndreuCodina Jul 2, 2024
6b7d057
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Jul 4, 2024
db1e42e
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Jul 7, 2024
c57ad80
Merge branch 'main' into feature/azure-key-vault-source-settings-source
AndreuCodina Jul 12, 2024
2bafd5d
Add AzureKeyVaultMapping
AndreuCodina Jul 15, 2024
e95d900
Fix tests
AndreuCodina Jul 15, 2024
df2ad20
Resolve comments
AndreuCodina Jul 15, 2024
df81e61
Update docs/index.md
hramezani Jul 18, 2024
1142877
Update docs/index.md
hramezani Jul 18, 2024
86eb90c
Update docs/index.md
hramezani Jul 18, 2024
5a682f5
Resolve comment
AndreuCodina Jul 18, 2024
5b875a6
Resolve comment
AndreuCodina Jul 18, 2024
1a79594
Update docs/index.md
hramezani Jul 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
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

```
AndreuCodina marked this conversation as resolved.
Show resolved Hide resolved
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('_', '-')
AndreuCodina marked this conversation as resolved.
Show resolved Hide resolved

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
Loading