Use file secrets in nested pydantic-settings models instead of built-in SecretsSettingsSource
This project is inspired by discussions in Pydantic Settings repository and proposes solution to #30 and #154.
- Plain or nested directory layout:
secrets/dir__keyorsecrets/dir/key - Respects
env_prefix,env_nested_delimiterand other config options - Implements config options
secrets_prefix,secrets_nested_delimiterand more to configure secrets and env vars independently - Drop-in replacement of standard
SecretsSettingsSource - Pure Python thin wrapper over standard
EnvSettingsSource - No third party dependencies except
pydantic-settings - Fully typed
- 100% test coverage
$ pip install pydantic-file-secretsNested Pydantic config can contain nested models with secret entries, as well as secrets in top level config. In dockerized environment, these entries may be read from file system, e.g. /run/secrets when using Docker Secrets:
from pydantic import BaseModel, Secret
from pydantic_settings import BaseSettings, SettingsConfigDict
class DbSettings(BaseModel):
user: str
passwd: Secret[str] # secret in nested model
class Settings(BaseSettings):
app_key: Secret[str] # secret in root model
db: DbSettings
model_config = SettingsConfigDict(
secrets_dir='/run/secrets',
)π secrets
βββ π app_key
βββ π db__passwd
from pydantic import BaseModel, SecretStr
from pydantic_file_secrets import FileSecretsSettingsSource, SettingsConfigDict
from pydantic_settings import BaseSettings
from pydantic_settings.sources import PydanticBaseSettingsSource
class DbSettings(BaseModel):
passwd: SecretStr
class Settings(BaseSettings):
app_key: SecretStr
db: DbSettings
model_config = SettingsConfigDict(
secrets_dir='secrets',
secrets_nested_delimiter='__',
)
@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 (
init_settings,
env_settings,
dotenv_settings,
FileSecretsSettingsSource(file_secret_settings),
)>>> Settings().model_dump()
{'app_key': SecretStr('**********'), 'db': {'passwd': SecretStr('**********')}}Config option secrets_nested_delimiter overrides env_nested_delimiter for files. In particular, this allows to use nested directory layout along with environmemt variables for other non-secret settings:
π secrets
βββ π app_key
βββ π db
βββ π passwd
model_config = SettingsConfigDict(
secrets_dir='secrets',
secrets_nested_subdir=True,
)>>> Settings().model_dump()
{'app_key': SecretStr('**********'), 'db': {'passwd': SecretStr('**********')}}π secrets
βββ π layer1
β βββ π app_key
βββ π layer2
βββ π db__passwd
model_config = SettingsConfigDict(
secrets_dir=['secrets/layer1', 'secrets/layer2'],
secrets_nested_delimiter='__',
)>>> Settings().model_dump()
{'app_key': SecretStr('**********'), 'db': {'passwd': SecretStr('**********')}}Caution
This syntax may change at any time. Pin current pydantic-file-secrets version if decided to use it.
Few important things to note:
@with_builtin_sourcesdecorator enablesNamedTupleargumentsrc: BuiltinSourcesencapsulating default builtins settings sourcesBaseSourcealias is shorter thanPydanticBaseSettingsSourceand is easier to use in type hintssettings_clswas removed fromsettings_customise_sourcessignature:clsseems to be sufficient
from pydantic import BaseModel, SecretStr
from pydantic_file_secrets import (
BaseSource,
BuiltinSources,
FileSecretsSettingsSource,
SettingsConfigDict,
with_builtin_sources,
)
from pydantic_settings import BaseSettings
class DbSettings(BaseModel):
passwd: SecretStr
class Settings(BaseSettings):
app_key: SecretStr
db: DbSettings
model_config = SettingsConfigDict(
secrets_dir='secrets',
secrets_nested_delimiter='__',
)
@classmethod
@with_builtin_sources
def settings_customise_sources(cls, src: BuiltinSources) -> tuple[BaseSource, ...]:
return (
src.init_settings,
src.env_settings,
src.dotenv_settings,
FileSecretsSettingsSource(src.file_secret_settings),
)>>> Settings().model_dump()
{'app_key': SecretStr('**********'), 'db': {'passwd': SecretStr('**********')}}Path to secrets directory. Same as SecretsSettingsSource.secrets_dir if str or Path. If list, the last match wins. If secrets_dir is passed in both source constructor and model config, values are not merged (constructor takes priority).
If secrets_dir does not exist, original SecretsSettingsSource issues a warning. However, this may be undesirable, for example if we don't mount Docker Secrets in e.g. dev environment. Now you have a choice:
'ok'β do nothing ifsecrets_dirdoes not exist'warn'(default) β print warning, same asSecretsSettingsSource'error'β raiseSettingsError
If multiple secrets_dir passed, the same secrets_dir_missing action applies to each of them.
Limit the size of secrets_dir for security reasons, defaults to SECRETS_DIR_MAX_SIZE equal to 16 MiB.
FileSecretsSettingsSource is a thin wrapper around EnvSettingsSource, which loads all potential secrets on initialization. This could lead to MemoryError if we mount a large file under secrets_dir.
If multiple secrets_dir passed, the limit applies to each directory independently.
Same as case_sensitive, but works for secrets only. If not specified, defaults to case_sensitive.
Same as env_nested_delimiter, but works for secrets only. If not specified, defaults to env_nested_delimiter. This option is used to implement nested secrets directory layout and allows to do even nastier things like /run/secrets/model/delim/nested1/delim/nested2.
Boolean flag to turn on nested secrets directory mode, False by default. If True, sets secrets_nested_delimiter to os.sep. Raises SettingsError if secrets_nested_delimiter is already specified.
Secret path prefix, similar to env_prefix, but works for secrets only. Defaults to env_prefix if not specified. Works in both plain and nested directory modes, like '/run/secrets/prefix_model__nested' and '/run/secrets/prefix_model/nested'.
Some config options that are declared in SecretsSettingsSource interface are actually not working and are not supported in FileSecretsSettingsSource:
env_ignore_emptyenv_parse_none_strenv_parse_enums
However, we make sure that the behaviour of FileSecretsSettingsSource matches SecretsSettingsSource to provide a drop-in replacement, although it is somewhat wierd (e.g. env_parse_enums is always True).
100% test coverage is provided for latest Python and pydantic-settings version. Tests are run for all minor pydantic-settings v2 versions and all minor Python 3 versions supported by them:
pyXYβ Python 3.{8,9,10,11,12,13}psXYβ pydantic-settings v2.{2,3,4,5,6,7,8,9,10}
| ps210 | ps29 | ps28 | ps27 | ps26 | ps25 | ps24 | ps23 | ps22 | |
|---|---|---|---|---|---|---|---|---|---|
| py313 | β³οΈ | β | β³οΈ | β | β | β | βοΈ | β³οΈ | βοΈ |
| py312 | β | β | β | β | β | β | βοΈ | βοΈ | βοΈ |
| py311 | β | β | β | β | β | β | βοΈ | βοΈ | βοΈ |
| py310 | β | β | β | β | β | β | βοΈ | βοΈ | βοΈ |
| py39 | β | β | β | β | β | β | βοΈ | βοΈ | βοΈ |
| py38 | β | β | β | β | β | β | βοΈ | βοΈ | βοΈ |
- β³οΈ pytest and mypy passing, coverage report generated
- β pytest and mypy passing
- βοΈ pytest passing, mypy not attempted
- β tests failing or not attempted
- September 2024 β Multiple secrets_dir feature was merged to pydantic-settings v2.5.0
Pull requests, feature requests, and bug reports are welcome!
- Michael Makukha