Skip to content

Commit dd3965d

Browse files
committed
Add support for AWS Secrets Manager
1 parent a1710b2 commit dd3965d

File tree

7 files changed

+493
-86
lines changed

7 files changed

+493
-86
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
fail-fast: false
3737
matrix:
3838
os: [ubuntu-latest, macos-latest, windows-latest]
39-
python: ['3.9', '3.10', '3.11', '3.12', '3.13']
39+
python: ['3.10', '3.11', '3.12', '3.13']
4040

4141
env:
4242
PYTHON: ${{ matrix.python }}

docs/index.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,61 @@ Last, run your application inside a Docker container and supply your newly creat
17751775
docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest
17761776
```
17771777

1778+
## AWS Secrets Manager
1779+
1780+
You must set one parameter:
1781+
1782+
- `secret_id`: The AWS secret id
1783+
1784+
You must have the same naming convention in the key value in secret as in the field name. For example, if the key in secret is named `SqlServerPassword`, the field name must be the same. You can use an alias too.
1785+
1786+
In Key Vault, nested models are supported with the `--` separator. For example, `SqlServer--Password`.
1787+
1788+
Key Vault arrays (e.g. `MySecret--0`, `MySecret--1`) are not supported.
1789+
1790+
```py
1791+
import os
1792+
1793+
from pydantic import BaseModel
1794+
1795+
from pydantic_settings import (
1796+
AWSSecretsManagerSettingsSource,
1797+
BaseSettings,
1798+
PydanticBaseSettingsSource,
1799+
)
1800+
1801+
1802+
class SubModel(BaseModel):
1803+
a: str
1804+
1805+
1806+
class AWSSecretsManagerSettings(BaseSettings):
1807+
foo: str
1808+
bar: int
1809+
sub: SubModel
1810+
1811+
@classmethod
1812+
def settings_customise_sources(
1813+
cls,
1814+
settings_cls: type[BaseSettings],
1815+
init_settings: PydanticBaseSettingsSource,
1816+
env_settings: PydanticBaseSettingsSource,
1817+
dotenv_settings: PydanticBaseSettingsSource,
1818+
file_secret_settings: PydanticBaseSettingsSource,
1819+
) -> tuple[PydanticBaseSettingsSource, ...]:
1820+
aws_secrets_manager_settings = AWSSecretsManagerSettingsSource(
1821+
settings_cls,
1822+
os.environ['AWS_SECRETS_MANAGER_SECRET_ID'],
1823+
)
1824+
return (
1825+
init_settings,
1826+
env_settings,
1827+
dotenv_settings,
1828+
file_secret_settings,
1829+
aws_secrets_manager_settings,
1830+
)
1831+
```
1832+
17781833
## Azure Key Vault
17791834

17801835
You must set two parameters:

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .main import BaseSettings, CliApp, SettingsConfigDict
22
from .sources import (
33
CLI_SUPPRESS,
4+
AWSSecretsManagerSettingsSource,
45
AzureKeyVaultSettingsSource,
56
CliExplicitFlag,
67
CliImplicitFlag,
@@ -49,6 +50,7 @@
4950
'SettingsError',
5051
'TomlConfigSettingsSource',
5152
'YamlConfigSettingsSource',
53+
'AWSSecretsManagerSettingsSource',
5254
'AzureKeyVaultSettingsSource',
5355
'get_subcommand',
5456
'__version__',

pydantic_settings/sources.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ def import_toml() -> None:
9696
import tomllib
9797

9898

99+
def import_aws_secrets_manager() -> None:
100+
global boto3_client
101+
global SecretsManagerClient
102+
103+
try:
104+
from boto3 import client as boto3_client
105+
from mypy_boto3_secretsmanager.client import SecretsManagerClient
106+
except ImportError as e:
107+
raise ImportError(
108+
'AWS Secrets Manager dependencies are not installed, run `pip install pydantic-settings[aws-secrets-manager]`'
109+
) from e
110+
111+
99112
def import_azure_key_vault() -> None:
100113
global TokenCredential
101114
global SecretClient
@@ -2188,6 +2201,43 @@ def __repr__(self) -> str:
21882201
return f'{self.__class__.__name__}(yaml_file={self.yaml_file_path})'
21892202

21902203

2204+
class AWSSecretsManagerSettingsSource(EnvSettingsSource):
2205+
_secret_id: str
2206+
_secretsmanager_client: SecretsManagerClient # type: ignore
2207+
2208+
def __init__(
2209+
self,
2210+
settings_cls: type[BaseSettings],
2211+
secret_id: str,
2212+
env_prefix: str | None = None,
2213+
env_parse_none_str: str | None = None,
2214+
env_parse_enums: bool | None = None,
2215+
) -> None:
2216+
import_aws_secrets_manager()
2217+
self._secretsmanager_client = boto3_client('secretsmanager') # type: ignore
2218+
self._secret_id = secret_id
2219+
super().__init__(
2220+
settings_cls,
2221+
case_sensitive=True,
2222+
env_prefix=env_prefix,
2223+
env_nested_delimiter='--',
2224+
env_ignore_empty=False,
2225+
env_parse_none_str=env_parse_none_str,
2226+
env_parse_enums=env_parse_enums,
2227+
)
2228+
2229+
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
2230+
response = self._secretsmanager_client.get_secret_value(SecretId=self._secret_id)
2231+
2232+
return json.loads(response['SecretString'])
2233+
2234+
def __repr__(self) -> str:
2235+
return (
2236+
f'{self.__class__.__name__}(secret_id={self._secret_id!r}, '
2237+
f'env_nested_delimiter={self.env_nested_delimiter!r})'
2238+
)
2239+
2240+
21912241
class AzureKeyVaultMapping(Mapping[str, Optional[str]]):
21922242
_loaded_secrets: dict[str, str | None]
21932243
_secret_client: SecretClient # type: ignore

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ classifiers = [
2020
'Programming Language :: Python',
2121
'Programming Language :: Python :: 3',
2222
'Programming Language :: Python :: 3 :: Only',
23-
'Programming Language :: Python :: 3.9',
2423
'Programming Language :: Python :: 3.10',
2524
'Programming Language :: Python :: 3.11',
2625
'Programming Language :: Python :: 3.12',
@@ -38,7 +37,7 @@ classifiers = [
3837
'Topic :: Software Development :: Libraries :: Python Modules',
3938
'Topic :: Internet',
4039
]
41-
requires-python = '>=3.9'
40+
requires-python = '>=3.10'
4241
dependencies = [
4342
'pydantic>=2.7.0',
4443
'python-dotenv>=0.21.0',
@@ -47,9 +46,10 @@ dependencies = [
4746
dynamic = ['version']
4847

4948
[project.optional-dependencies]
50-
yaml = ["pyyaml>=6.0.1"]
49+
yaml = ["pyyaml>=6.0.2"]
5150
toml = ["tomli>=2.0.1"]
5251
azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"]
52+
aws-secrets-manager = ["boto3>=1.35.0", "boto3-stubs[secretsmanager]"]
5353

5454
[project.urls]
5555
Homepage = 'https://github.com/pydantic/pydantic-settings'
@@ -66,20 +66,23 @@ linting = [
6666
"pyyaml",
6767
"ruff",
6868
"types-pyyaml",
69+
"boto3-stubs[secretsmanager]",
6970
]
7071
testing = [
7172
"coverage[toml]",
7273
"pytest",
7374
"pytest-examples",
7475
"pytest-mock",
7576
"pytest-pretty",
77+
"moto[secretsmanager]",
7678
]
7779

7880
[tool.pytest.ini_options]
7981
testpaths = 'tests'
8082
filterwarnings = [
8183
'error',
8284
'ignore:This is a placeholder until pydantic-settings.*:UserWarning',
85+
'ignore::DeprecationWarning:botocore.*:',
8386
]
8487

8588
[tool.coverage.run]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
Test pydantic_settings.AWSSecretsManagerSettingsSource.
3+
"""
4+
5+
import json
6+
import os
7+
8+
import pytest
9+
10+
try:
11+
import yaml
12+
from moto import mock_aws
13+
except ImportError:
14+
yaml = None
15+
mock_aws = None
16+
17+
from pydantic import BaseModel, Field
18+
19+
from pydantic_settings import (
20+
AWSSecretsManagerSettingsSource,
21+
BaseSettings,
22+
PydanticBaseSettingsSource,
23+
)
24+
from pydantic_settings.sources import import_aws_secrets_manager
25+
26+
try:
27+
aws_secrets_manager = True
28+
import_aws_secrets_manager()
29+
import boto3
30+
31+
os.environ['AWS_DEFAULT_REGION'] = os.environ.get('AWS_DEFAULT_REGION', 'us-east-1')
32+
except ImportError:
33+
aws_secrets_manager = False
34+
35+
36+
MODULE = 'pydantic_settings.sources'
37+
38+
if not yaml:
39+
pytest.skip('PyYAML is not installed', allow_module_level=True)
40+
41+
42+
@pytest.mark.skipif(not aws_secrets_manager, reason='pydantic-settings[aws-secrets-manager] is not installed')
43+
class TestAWSSecretsManagerSettingsSource:
44+
"""Test AWSSecretsManagerSettingsSource."""
45+
46+
@mock_aws
47+
def test___init__(self) -> None:
48+
"""Test __init__."""
49+
50+
class AWSSecretsManagerSettings(BaseSettings):
51+
"""AWSSecretsManager settings."""
52+
53+
client = boto3.client('secretsmanager')
54+
client.create_secret(Name='test-secret', SecretString='{}')
55+
56+
AWSSecretsManagerSettingsSource(AWSSecretsManagerSettings, 'test-secret')
57+
58+
@mock_aws
59+
def test___call__(self) -> None:
60+
"""Test __call__."""
61+
62+
class SqlServer(BaseModel):
63+
password: str = Field(..., alias='Password')
64+
65+
class AWSSecretsManagerSettings(BaseSettings):
66+
"""AWSSecretsManager settings."""
67+
68+
SqlServerUser: str
69+
sql_server_user: str = Field(..., alias='SqlServerUser')
70+
sql_server: SqlServer = Field(..., alias='SqlServer')
71+
72+
expected_secret_value = 'SecretValue'
73+
secret_data = {'SqlServerUser': expected_secret_value, 'SqlServer--Password': expected_secret_value}
74+
75+
client = boto3.client('secretsmanager')
76+
client.create_secret(Name='test-secret', SecretString=json.dumps(secret_data))
77+
78+
obj = AWSSecretsManagerSettingsSource(AWSSecretsManagerSettings, 'test-secret')
79+
80+
settings = obj()
81+
82+
assert settings['SqlServerUser'] == expected_secret_value
83+
assert settings['SqlServer']['Password'] == expected_secret_value
84+
85+
@mock_aws
86+
def test_aws_secrets_manager_settings_source(self) -> None:
87+
"""Test AWSSecretsManagerSettingsSource."""
88+
89+
class SqlServer(BaseModel):
90+
password: str = Field(..., alias='Password')
91+
92+
class AWSSecretsManagerSettings(BaseSettings):
93+
"""AWSSecretsManager settings."""
94+
95+
SqlServerUser: str
96+
sql_server_user: str = Field(..., alias='SqlServerUser')
97+
sql_server: SqlServer = Field(..., alias='SqlServer')
98+
99+
@classmethod
100+
def settings_customise_sources(
101+
cls,
102+
settings_cls: type[BaseSettings],
103+
init_settings: PydanticBaseSettingsSource,
104+
env_settings: PydanticBaseSettingsSource,
105+
dotenv_settings: PydanticBaseSettingsSource,
106+
file_secret_settings: PydanticBaseSettingsSource,
107+
) -> tuple[PydanticBaseSettingsSource, ...]:
108+
return (AWSSecretsManagerSettingsSource(settings_cls, 'test-secret'),)
109+
110+
expected_secret_value = 'SecretValue'
111+
secret_data = {'SqlServerUser': expected_secret_value, 'SqlServer--Password': expected_secret_value}
112+
113+
client = boto3.client('secretsmanager')
114+
client.create_secret(Name='test-secret', SecretString=json.dumps(secret_data))
115+
116+
settings = AWSSecretsManagerSettings() # type: ignore
117+
118+
assert settings.SqlServerUser == expected_secret_value
119+
assert settings.sql_server_user == expected_secret_value
120+
assert settings.sql_server.password == expected_secret_value

0 commit comments

Comments
 (0)